From e7bbc7c9f5805416bbaa88f8db89469887523489 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 19 Aug 2026 05:27:44 -0400 Subject: [PATCH] Add bilingual day-label headers with Latin weekday/season/ordinal vocabulary Builds every day label as a {en, la} pair (weekdays, ranks, season/ordinal phrasing authored in Latin; proper names without an authored Latin form fall back to their English string) and merges the date+day-label into one header in the day-nav bar. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 36 +++ src/calendar/day-label.ts | 306 ++++++++++++++++++------- src/hours/types.ts | 12 +- src/ui/day-nav.ts | 21 +- src/ui/format.ts | 55 ++++- src/ui/hour-view.ts | 1 - src/ui/styles.css | 26 ++- tests/calendar/christ-the-king.test.ts | 2 +- tests/calendar/day-label.test.ts | 91 +++++--- tests/calendar/marian-saturday.test.ts | 2 +- tests/hours/compline.test.ts | 2 +- tests/ui/shell.test.ts | 4 + 12 files changed, 415 insertions(+), 143 deletions(-) diff --git a/TODO.md b/TODO.md index b31f3e8..8945a8e 100644 --- a/TODO.md +++ b/TODO.md @@ -27,6 +27,11 @@ across every hour and content type in this app. 2. **Minimal-proof-done, bulk content pending** — mechanism is built and trusted (a proof date/entry already resolves correctly end-to-end); what's left is authoring/importing more content in that same shape across the rest of the calendar: + - Latin saint/octave/named-feast names for the bilingual date/day-label header (see + "Bilingual, merged date/day-label header" below) — the `{en, la}` mechanism is built and + every proper name falls back to English gracefully, but zero saints, octaves, or named + temporal feasts have an authored Latin name anywhere in `src/data/**`. Weekday names, + month names, rank labels, and season/ordinal phrasing already have real Latin. - Matins: bible-plan TSV bulk conversion is done (see "Bible-plan TSV — bulk conversion done" below) — not yet added to a test table, though (unlike sanctoral/temporal content) there's no reference breviary to spot-check this plan against by design. Still open: @@ -2156,6 +2161,37 @@ Trinity" for their own last Sunday, where before the fix they'd shown four diffe Tests in `tests/calendar/day-label.test.ts` updated accordingly. `npm test` (365 tests) and `tsc --noEmit` both pass. +### Bilingual, merged date/day-label header — done (2026-08-18) + +The civil date/weekday (`day-nav`'s date span) and the "day being celebrated" text +(`getDayLabel`, previously repeated inside every hour's own view) are now merged into one +header block in `day-nav.ts`, and both are bilingual. `getDayLabel()` returns +`Partial>` (`{en, la}`) instead of a plain English string, matching +`hours/types.ts`'s existing bilingual convention; `formatDateLong()` (`src/ui/format.ts`) is +bilingual too, since `Intl` has no Latin locale data (`MONTH_LABELS_LA` authored directly). +Latin weekday names are spelled out per direct instruction — "Feria Tertia", not "Feria III" +— `Dominica`/`Feria Secunda`...`Sexta`/`Sabbato`. Advent and Lent specifically drop the +redundant leading weekday word from `temporalLabel()` now that the header always shows the +weekday on its own ("Tuesday in the 2nd week of Advent" → "In the 2nd week of Advent") — every +other season (Epiphanytide, Eastertide, Trinitytide, the plain fallback) keeps its weekday +prefix, a deliberate narrower-scope decision, not an oversight. + +**Real content gap, left open on purpose**: no Latin form exists anywhere for saint names +(`SanctoralIdentity.name`), octave names, or named-temporal-feast names — only rank labels, +weekday names, month names, and season/ordinal phrasing have authored Latin. Every saint/ +octave/feast name currently falls back to its English string as its own `la` value too (same +"surface real text over missing" convention used elsewhere), so Latin mode never shows a blank +where a proper name belongs — but it also means Latin mode still reads mostly-English for any +day with a real winner. Authoring actual Latin names per saint/octave/feast is separate future +content work (hundreds of records), not started here. + +`tests/calendar/day-label.test.ts` rewritten for the `{en, la}` shape (all `.en` values +unchanged except the Advent/Lent weekday-drop cases), plus new representative `.la` +assertions (weekday, rank, season-ordinal genitive construction, English-fallback-for-a- +saint-name). `tests/hours/compline.test.ts` and `tests/ui/shell.test.ts` updated too. `npm +test` (383 tests) and `tsc --noEmit` both pass; manually verified in the browser (English, +Latin, and both-language modes) against an Advent date with an active octave. + ## Known, deliberate simplifications (not bugs — working as designed) - `getDayCollects`: each collect in a multi-collect day renders as its own diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 4ba7198..f5db2d2 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -9,7 +9,16 @@ // 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 { FeastClass, LiturgicalDay, TemporalCategory } from './types'; +// +// Bilingual: every label is built as a `Bi` (`{en, la}`) pair throughout. +// Proper names (a saint's name, an octave's name, a named temporal feast's +// name) have no authored Latin form anywhere in `data/` today — those use +// the English string as their own `la` value too (same "surface real text +// rather than block on missing" convention used elsewhere in this app), +// rather than leaving Latin mode showing blanks. Only the closed, +// mechanism-level vocabulary here (weekdays, ranks, season/ordinal +// phrasing) has real Latin authored. +import type { FeastClass, LiturgicalDay, TemporalCategory, Weekday } from './types'; import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; @@ -17,6 +26,26 @@ import { getTemporalFeastRecord } from './temporal-feasts'; import { activeOctavesFor, octaveGoverningPrivilegedDay, resolveActiveOctave, type ActiveOctave } from './octaves'; import { resolveTemporalId } from './temporal-id'; +interface Bi { + en: string; + la: string; + [lang: string]: string; +} + +function bi(en: string, la: string): Bi { + return { en, la }; +} + +/** A proper name with no authored Latin form yet — the English string + * stands in for `la` too. See file-header note. */ +function biFallback(name: string): Bi { + return { en: name, la: name }; +} + +function joinBi(items: Bi[], sep = ' — '): Bi { + return { en: items.map((i) => i.en).join(sep), la: items.map((i) => i.la).join(sep) }; +} + function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } @@ -38,6 +67,56 @@ function ordinal(n: number): string { } } +// Feminine Latin ordinal adjectives (agreeing with "Dominica"/"Hebdomada"/ +// "die", all feminine) — covers 1st through 24th, the full range this app +// ever counts to (Trinitytide's fixed last week is the 23rd). Not a general +// Latin-numerals utility, just this app's own closed display vocabulary. +const ORDINAL_LA = [ + '', + 'prima', + 'secunda', + 'tertia', + 'quarta', + 'quinta', + 'sexta', + 'septima', + 'octava', + 'nona', + 'decima', + 'undecima', + 'duodecima', + 'decima tertia', + 'decima quarta', + 'decima quinta', + 'decima sexta', + 'decima septima', + 'decima octava', + 'decima nona', + 'vicesima', + 'vicesima prima', + 'vicesima secunda', + 'vicesima tertia', + 'vicesima quarta', +]; + +function ordinalLa(n: number): string { + return ORDINAL_LA[n] ?? `${n}a`; +} + +const WEEKDAY_LABELS: Record = { + sunday: bi('Sunday', 'Dominica'), + monday: bi('Monday', 'Feria Secunda'), + tuesday: bi('Tuesday', 'Feria Tertia'), + wednesday: bi('Wednesday', 'Feria Quarta'), + thursday: bi('Thursday', 'Feria Quinta'), + friday: bi('Friday', 'Feria Sexta'), + saturday: bi('Saturday', 'Sabbato'), +}; + +function weekdayLabel(day: LiturgicalDay): Bi { + return WEEKDAY_LABELS[day.weekday]; +} + interface OrdinalSeason { /** ISO date of the season's own anchor day, for a given calendar year. */ anchorDate(year: number): string; @@ -45,10 +124,16 @@ interface OrdinalSeason { * Lent: the anchor is its own named day, excluded from the count — the * numbered weeks start the following Sunday. */ includeAnchorWeek: boolean; - /** Used in "Weekday after {anchorName}" for the anchor's own partial week. */ - anchorName: string; - /** Used in "the Nth Sunday/week {preposition} {ordinalName}". */ - ordinalName: string; + /** Used in "Weekday after {anchorName}" for the anchor's own partial week, + * and as the anchor day's own standalone name. */ + anchorName: Bi; + /** Used in "the Nth Sunday/week {preposition} {ordinalName}". For + * `preposition: 'of'`, `ordinalName.la` is already the correct genitive + * form (glued directly after the ordinal, no linking word — Latin + * idiom). For `preposition: 'after'`, `ordinalName.la` is the form used + * after "post" (traditionally accusative, or an idiomatic fixed phrase + * like "post Cineres"). */ + ordinalName: Bi; preposition: 'after' | 'of'; /** The season's own anchor day's rank, if it sits on the closed * pre-1955 duplex scale at all — Ash Wednesday (Lent's anchor) doesn't; @@ -56,44 +141,61 @@ interface OrdinalSeason { * and shows no parenthetical, same "omit rather than guess" convention * `formatRank`/`status` fields use elsewhere. */ anchorRank?: FeastClass; + /** Advent and Lent only: once the header always shows the day's weekday + * on its own, repeating it at the front of this label is redundant — so + * those two seasons drop it here, in both languages. Every other season + * keeps the weekday prefix (explicit, narrower scope decision). */ + dropWeekdayPrefix?: boolean; } const ORDINAL_SEASONS: Partial> = { advent: { anchorDate: adventStart, includeAnchorWeek: true, - anchorName: 'Advent', - ordinalName: 'Advent', + anchorName: bi('Advent', 'Adventus'), + // Genitive of 4th-declension "adventus" is also "Adventus" — no + // linking word needed: "Hebdomada II Adventus". + ordinalName: bi('Advent', 'Adventus'), preposition: 'of', + dropWeekdayPrefix: true, }, epiphanytide: { anchorDate: (year) => `${year}-01-06`, includeAnchorWeek: false, - anchorName: 'Epiphany', - ordinalName: 'Epiphany', + anchorName: bi('Epiphany', 'Epiphania'), + // Accusative, for "post Epiphaniam". + ordinalName: bi('Epiphany', 'Epiphaniam'), preposition: 'after', anchorRank: 'duplex-1-classis', }, lent: { anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), -46), includeAnchorWeek: false, - anchorName: 'Ash Wednesday', - ordinalName: 'Lent', + anchorName: bi('Ash Wednesday', 'Feria Quarta Cinerum'), + // Genitive of 1st-declension "Quadragesima": "Hebdomada II + // Quadragesimae". The pre-Lent-I days use the real traditional idiom + // "post Cineres" instead (see temporalLabel below), not this field. + ordinalName: bi('Lent', 'Quadragesimae'), preposition: 'of', + dropWeekdayPrefix: true, }, eastertide: { anchorDate: (year) => toIsoDate(easterSunday(year)), includeAnchorWeek: false, - anchorName: 'Easter', - ordinalName: 'Easter', + anchorName: bi('Easter', 'Pascha'), + // "Pascha" is indeclinable — same form after "post". + ordinalName: bi('Easter', 'Pascha'), preposition: 'after', anchorRank: 'duplex-1-classis', }, trinitytide: { anchorDate: (year) => addDays(toIsoDate(easterSunday(year)), 56), includeAnchorWeek: false, - anchorName: 'Trinity Sunday', - ordinalName: 'Trinity', + anchorName: bi('Trinity Sunday', 'Dominica Sanctissimae Trinitatis'), + // Accusative, for "post Sanctissimam Trinitatem" — this app's own + // Trinity-counted convention (see file header), not the historical + // Pentecost-counted "post Pentecosten". + ordinalName: bi('Trinity', 'Sanctissimam Trinitatem'), preposition: 'after', anchorRank: 'duplex-1-classis', }, @@ -107,14 +209,16 @@ const ORDINAL_SEASONS: Partial> = { * octave, but nobody calls it that). Advent's own anchor (Advent I Sunday) * doesn't take this branch — it's already "the 1st Sunday of Advent" via * the ordinal path below. */ -function anchorDayName(day: LiturgicalDay, withRank = true): string | undefined { +function anchorDayName(day: LiturgicalDay, withRank = true): Bi | undefined { const config = ORDINAL_SEASONS[day.season]; if (!config || config.includeAnchorWeek) { return undefined; } const year = Number(day.date.slice(0, 4)); if (day.date === config.anchorDate(year)) { - return withRank && config.anchorRank ? `${config.anchorName} (${formatRank(config.anchorRank)})` : config.anchorName; + return withRank && config.anchorRank + ? bi(`${config.anchorName.en} (${formatRank(config.anchorRank).en})`, `${config.anchorName.la} (${formatRank(config.anchorRank).la})`) + : config.anchorName; } return undefined; } @@ -147,47 +251,57 @@ const FIXED_LAST_SUNDAY_ORDINAL = 23; * every year, not just overflow ones. * Only ever fires within trinitytide, since that's the only season * resolveTemporalId can return either of these ids for a date outside - * their own native season. + * their own native season. Trinitytide isn't in the weekday-drop set, so + * both languages keep their weekday prefix here. */ -function trinitytideOverrideLabel(day: LiturgicalDay): string | undefined { +function trinitytideOverrideLabel(day: LiturgicalDay): Bi | undefined { if (day.season !== 'trinitytide') { return undefined; } const id = resolveTemporalId(day.date); - const weekdayName = capitalize(day.weekday); + const weekdayName = weekdayLabel(day); const epiphanyMatch = id.match(/^post-epiphany-(\d)$/); if (epiphanyMatch) { const n = Number(epiphanyMatch[1]); return day.weekday === 'sunday' - ? `The ${ordinal(n)} Sunday after Epiphany` - : `${weekdayName} in the ${ordinal(n)} week after Epiphany`; + ? bi(`The ${ordinal(n)} Sunday after Epiphany`, `Dominica ${ordinalLa(n)} post Epiphaniam`) + : bi(`${weekdayName.en} in the ${ordinal(n)} week after Epiphany`, `${weekdayName.la} in hebdomada ${ordinalLa(n)} post Epiphaniam`); } if (id === 'post-pentecost-24') { return day.weekday === 'sunday' - ? `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity` - : `${weekdayName} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`; + ? bi( + `The ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} Sunday after Trinity`, + `Dominica ${ordinalLa(FIXED_LAST_SUNDAY_ORDINAL)} post Sanctissimam Trinitatem`, + ) + : bi( + `${weekdayName.en} in the ${ordinal(FIXED_LAST_SUNDAY_ORDINAL)} week after Trinity`, + `${weekdayName.la} in hebdomada ${ordinalLa(FIXED_LAST_SUNDAY_ORDINAL)} post Sanctissimam Trinitatem`, + ); } return undefined; } -function temporalLabel(day: LiturgicalDay): string { +function temporalLabel(day: LiturgicalDay): Bi { const trinitytideOverride = trinitytideOverrideLabel(day); if (trinitytideOverride) { return trinitytideOverride; } - const weekdayName = capitalize(day.weekday); + const weekdayName = weekdayLabel(day); const config = ORDINAL_SEASONS[day.season]; if (!config) { // No ordinal convention modeled for this season (Septuagesima-tide, // Passiontide, Ascensiontide, Pentecost, Christmastide, the // Corpus-Christi/Sacred-Heart single-day seasons) — their few days // mostly have their own proper names rather than ordinal counting, so - // this fallback is expected to be seen, not a gap to fill later. - return `${weekdayName} in ${capitalize(day.season.replace(/-/g, ' '))}`; + // this fallback is expected to be seen, not a gap to fill later. No + // authored Latin season name exists for these yet, so `la` falls back + // to the same English season word. + const seasonName = capitalize(day.season.replace(/-/g, ' ')); + return bi(`${weekdayName.en} in ${seasonName}`, `${weekdayName.la} in ${seasonName}`); } const year = Number(day.date.slice(0, 4)); @@ -199,34 +313,54 @@ function temporalLabel(day: LiturgicalDay): string { const firstNumberedSunday = config.includeAnchorWeek ? anchor : firstSundayStrictlyAfter(anchor); if (day.date < firstNumberedSunday) { - return `${weekdayName} after ${config.anchorName}`; + // Lent's own pre-Lent-I days use the real traditional idiom "post + // Cineres" rather than the season's genitive `ordinalName` (which is + // built for the "of Lent" construction, not "after Ash Wednesday"). + const afterAnchorLa = day.season === 'lent' ? 'Cineres' : config.anchorName.la; + return config.dropWeekdayPrefix + ? bi(`After ${config.anchorName.en}`, `Post ${afterAnchorLa}`) + : bi(`${weekdayName.en} after ${config.anchorName.en}`, `${weekdayName.la} post ${afterAnchorLa}`); } const weeksSince = daysBetween(firstNumberedSunday, sundayOnOrBefore(day.date)) / 7; const ordinalStr = ordinal(weeksSince + 1); + const ordinalStrLa = ordinalLa(weeksSince + 1); if (day.weekday === 'sunday') { - return `The ${ordinalStr} Sunday ${config.preposition} ${config.ordinalName}`; + return config.preposition === 'of' + ? bi(`The ${ordinalStr} Sunday of ${config.ordinalName.en}`, `Dominica ${ordinalStrLa} ${config.ordinalName.la}`) + : bi(`The ${ordinalStr} Sunday after ${config.ordinalName.en}`, `Dominica ${ordinalStrLa} post ${config.ordinalName.la}`); } - return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`; + const weekPhraseLa = + config.preposition === 'of' ? `hebdomada ${ordinalStrLa} ${config.ordinalName.la}` : `hebdomada ${ordinalStrLa} post ${config.ordinalName.la}`; + return config.dropWeekdayPrefix + ? bi(`In the ${ordinalStr} week ${config.preposition} ${config.ordinalName.en}`, `In ${weekPhraseLa}`) + : bi(`${weekdayName.en} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName.en}`, `${weekdayName.la} in ${weekPhraseLa}`); } /** "Third Day within the Octave of St. Lawrence" for an ordinary mid-octave - * day (e.g. "Tertia die infra Octavam S. Laurentii Martyris"), or plain - * "Octave of St. Lawrence" for the octave's own closing day — real DO - * Latin distinguishes "infra octavam" (within the octave, days 2-7) from - * "in octava" (on the octave day itself, day 8), live-verified: Aug 17 - * (St. Lawrence's closing day) titles itself "In Octava S. Laurentii - * Martyris", not "Octava die infra Octavam...". Day 1 shouldn't normally - * reach either branch (that day's own winner is the feast itself, handled - * above before this is ever called) — kept simple rather than - * special-cased for that rare edge case (see applyOctaves's own - * `isOwnStartDay` comment in calendar/index.ts for when it can happen). */ -function octaveCoreName(octave: ActiveOctave): string { - return octave.isClosingDay ? `Octave of ${octave.name}` : `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`; + * day ("Tertia die infra Octavam S. Laurentii Martyris"), or plain "Octave + * of St. Lawrence" for the octave's own closing day ("In Octava S. + * Laurentii Martyris") — real DO Latin distinguishes "infra octavam" + * (within the octave, days 2-7) from "in octava" (on the octave day + * itself, day 8), live-verified: Aug 17 (St. Lawrence's closing day) + * titles itself "In Octava S. Laurentii Martyris", not "Octava die infra + * Octavam...". Day 1 shouldn't normally reach either branch (that day's + * own winner is the feast itself, handled above before this is ever + * called) — kept simple rather than special-cased for that rare edge case + * (see applyOctaves's own `isOwnStartDay` comment in calendar/index.ts for + * when it can happen). The octave's own `name` has no authored Latin form + * (see file header), so both languages currently show the same name text + * — only the surrounding phrasing differs. */ +function octaveCoreName(octave: ActiveOctave): Bi { + return octave.isClosingDay + ? bi(`Octave of ${octave.name}`, `In Octava ${octave.name}`) + : bi(`${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`, `${ordinalLa(octave.dayNumber)} die infra Octavam ${octave.name}`); } -function octaveLabel(octave: ActiveOctave): string { - return `${octaveCoreName(octave)} (${formatRank(octave.wins)})`; +function octaveLabel(octave: ActiveOctave): Bi { + const core = octaveCoreName(octave); + const rank = formatRank(octave.wins); + return bi(`${core.en} (${rank.en})`, `${core.la} (${rank.la})`); } /** Same phrasing as `octaveLabel`, but without the parenthesized rank — @@ -238,7 +372,7 @@ function octaveLabel(octave: ActiveOctave): string { * it *won as*, not a fact worth restating once a duplex-or-higher saint * has displaced it and it's just riding along as a commemoration * instead). */ -function octaveCommemorationLabel(octave: ActiveOctave): string { +function octaveCommemorationLabel(octave: ActiveOctave): Bi { return octaveCoreName(octave); } @@ -258,7 +392,8 @@ function octaveCommemorationLabel(octave: ActiveOctave): string { * - `sanctoral`: every commemorated saint, plain name, no rank (multiple * real ones can coexist — e.g. two colliding saints on the same date — * this app's own "generous commemorations" design keeps every one of - * them, not just the first). + * them, not just the first). No authored Latin saint names exist yet + * (see file header), so `la` falls back to the same English name. * - `temporal`: the day's own real Sunday/feria identity, if * `decideOccurrence` demoted it to a commemoration (`ordinary-sunday`/ * `privileged-feria`/`privileged-feria-minor`) — matched by id against @@ -282,35 +417,35 @@ function octaveCommemorationLabel(octave: ActiveOctave): string { function collectCommemorations( day: LiturgicalDay, opts: { showOctaves: boolean; excludeOctaveId?: string }, -): { sanctoral: string[]; temporal: string[]; octaves: string[] } { +): { sanctoral: Bi[]; temporal: Bi[]; octaves: Bi[] } { const temporalId = resolveTemporalId(day.date); const activeOctaves = opts.showOctaves ? activeOctavesFor(day.date) : []; - const sanctoral: string[] = []; - const temporal: string[] = []; - const octaves: string[] = []; + const sanctoral: Bi[] = []; + const temporal: Bi[] = []; + const octaves: Bi[] = []; for (const c of day.commemorations) { if (c.kind === 'sanctoral') { - sanctoral.push(c.name); + sanctoral.push(biFallback(c.name)); } else if (c.kind === 'temporal') { if (c.id === temporalId) { temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); } } else if (opts.showOctaves && c.id !== opts.excludeOctaveId) { const octave = activeOctaves.find((a) => a.id === c.id); - octaves.push(octave ? octaveCommemorationLabel(octave) : c.name); + octaves.push(octave ? octaveCommemorationLabel(octave) : biFallback(c.name)); } } return { sanctoral, temporal, octaves }; } -const RANK_LABELS: Record = { - simplex: 'Simplex', - vigil: 'Vigil', - semiduplex: 'Semiduplex', - duplex: 'Duplex', - 'duplex-majus': 'Duplex Majus', - 'duplex-2-classis': 'Duplex II Class', - 'duplex-1-classis': 'Duplex I Class', +const RANK_LABELS: Record = { + simplex: bi('Simplex', 'Simplex'), + vigil: bi('Vigil', 'Vigilia'), + semiduplex: bi('Semiduplex', 'Semiduplex'), + duplex: bi('Duplex', 'Duplex'), + 'duplex-majus': bi('Duplex Majus', 'Duplex Majus'), + 'duplex-2-classis': bi('Duplex II Class', 'Duplex II. Classis'), + 'duplex-1-classis': bi('Duplex I Class', 'Duplex I. Classis'), }; /** The day's own winning saint's rank, parenthesized after their name — @@ -318,7 +453,7 @@ const RANK_LABELS: Record = { * 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 { +function formatRank(rank: FeastClass): Bi { return RANK_LABELS[rank]; } @@ -330,14 +465,15 @@ function formatRank(rank: FeastClass): string { * Semiduplex regardless of whether it's additionally "privileged" * (privilege is about resisting supersession, a property of * `temporalCategory`, not a higher spot on the duplex scale) — so both - * Sunday categories share one label here. */ -const TEMPORAL_CATEGORY_RANK_LABELS: Record = { - 'ordinary-sunday': 'Semiduplex', - 'privileged-sunday': 'Semiduplex', - 'ordinary-feria': 'Feria', - 'privileged-feria-minor': 'Feria', - 'privileged-feria': 'Feria', - 'privileged-feria-major': 'Feria', + * Sunday categories share one label here. Both "Semiduplex" and "Feria" + * are already the real Latin words, so `en`/`la` coincide here too. */ +const TEMPORAL_CATEGORY_RANK_LABELS: Record = { + 'ordinary-sunday': bi('Semiduplex', 'Semiduplex'), + 'privileged-sunday': bi('Semiduplex', 'Semiduplex'), + 'ordinary-feria': bi('Feria', 'Feria'), + 'privileged-feria-minor': bi('Feria', 'Feria'), + 'privileged-feria': bi('Feria', 'Feria'), + 'privileged-feria-major': bi('Feria', 'Feria'), }; /** @@ -348,9 +484,12 @@ const TEMPORAL_CATEGORY_RANK_LABELS: Record = { * eligible to ride along with it. Each branch below only decides two * things: what the primary label is, and what commemoration *groups* are * eligible here and in what order — the actual filtering/formatting work - * is `collectCommemorations`'s alone, shared by every branch. + * is `collectCommemorations`'s alone, shared by every branch. Returns a + * `Partial>` keyed by language code (`en`/`la`), + * the same convention `hours/types.ts`'s `ResolvedText`/`ResolvedVerse` + * already use. */ -export function getDayLabel(day: LiturgicalDay): string { +export function getDayLabel(day: LiturgicalDay): Partial> { if (day.winner.kind === 'sanctoral') { // Octaves only ride along here on `ordinary-feria` — a day with real // standing of its own (a Sunday, a privileged feria) doesn't mention @@ -362,8 +501,10 @@ export function getDayLabel(day: LiturgicalDay): string { const { sanctoral, temporal, octaves } = collectCommemorations(day, { showOctaves: day.temporalCategory === 'ordinary-feria', }); - const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`; - return [winnerName, ...temporal, ...sanctoral, ...octaves].join(' — '); + const rank = formatRank(day.winner.rank); + const winnerName = biFallback(day.winner.name); + const winner = bi(`${winnerName.en} (${rank.en})`, `${winnerName.la} (${rank.la})`); + return joinBi([winner, ...temporal, ...sanctoral, ...octaves]); } // A named temporal feast (Christmas, Pentecost, Marian Saturday, ...) @@ -373,9 +514,12 @@ export function getDayLabel(day: LiturgicalDay): string { // temporal-feasts.ts) and fall through further down. const namedFeast = getTemporalFeastRecord(day.winner.id); if (namedFeast) { - const primary = namedFeast.rank ? `${namedFeast.name} (${formatRank(namedFeast.rank)})` : namedFeast.name; + const name = biFallback(namedFeast.name); + const primary = namedFeast.rank + ? bi(`${name.en} (${formatRank(namedFeast.rank).en})`, `${name.la} (${formatRank(namedFeast.rank).la})`) + : name; const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return [primary, ...sanctoral].join(' — '); + return joinBi([primary, ...sanctoral]); } // A season's own named anchor day (Trinity Sunday, Easter, Ash @@ -388,7 +532,7 @@ export function getDayLabel(day: LiturgicalDay): string { const anchorName = anchorDayName(day); if (anchorName) { const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return [...sanctoral, anchorName].join(' — '); + return joinBi([...sanctoral, anchorName]); } // An active octave (St. Lawrence's, ...) is this day's real primary @@ -424,10 +568,12 @@ export function getDayLabel(day: LiturgicalDay): string { if (activeOctave) { const { sanctoral, octaves } = collectCommemorations(day, { showOctaves: true, excludeOctaveId: activeOctave.id }); const primary = octaveLabel(activeOctave); - return [primary, ...octaves, ...sanctoral].join(' — '); + return joinBi([primary, ...octaves, ...sanctoral]); } - const temporal = `${temporalLabel(day)} (${TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]})`; + const temporalRank = TEMPORAL_CATEGORY_RANK_LABELS[day.temporalCategory]; + const label = temporalLabel(day); + const temporal = bi(`${label.en} (${temporalRank.en})`, `${label.la} (${temporalRank.la})`); const { sanctoral } = collectCommemorations(day, { showOctaves: false }); - return [...sanctoral, temporal].join(' — '); + return joinBi([...sanctoral, temporal]); } diff --git a/src/hours/types.ts b/src/hours/types.ts index 56c6f9b..a669d3d 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -269,11 +269,11 @@ export interface ResolvedOrdo { /** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */ notImplemented?: true; /** - * "Monday in the 10th week after Trinity", "St. Ereden — ...", etc. — - * see calendar/day-label.ts. Computed from whichever LiturgicalDay the - * hour actually resolved against, which can differ from `date` itself - * (Compline may anticipate tomorrow — see calendar/vespers.ts). Absent - * on not-yet-built hours. + * "Monday in the 10th week after Trinity", "St. Ereden — ...", etc., + * keyed by language code (`en`/`la`) — see calendar/day-label.ts. + * Computed from whichever LiturgicalDay the hour actually resolved + * against, which can differ from `date` itself (Compline may anticipate + * tomorrow — see calendar/vespers.ts). Absent on not-yet-built hours. */ - dayLabel?: string; + dayLabel?: Partial>; } diff --git a/src/ui/day-nav.ts b/src/ui/day-nav.ts index 4a4d82a..4076dd7 100644 --- a/src/ui/day-nav.ts +++ b/src/ui/day-nav.ts @@ -1,13 +1,28 @@ import { getState, shiftDate, goToToday } from '../app-state'; -import { formatDateLong } from './format'; +import { resolveDay } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; +import { formatDateLong, renderBilingual } from './format'; +/** Renders navigation plus the merged "when" header: the civil date/ + * weekday and the liturgical day-being-celebrated, together in one place + * — previously the date lived here while the day-being-celebrated text + * was repeated separately inside every hour's own view. The day-being- + * celebrated text is hour-independent (the same LiturgicalDay for every + * hour on a given date), so it's resolved directly here via `resolveDay` + * rather than through any particular hour's `resolveOrdo`. */ export function renderDayNav(container: HTMLElement): void { - const { date } = getState(); + const { date, languages } = getState(); + const day = resolveDay(date); + const dateLabel = formatDateLong(date); + const dayLabel = getDayLabel(day); container.innerHTML = ` `; diff --git a/src/ui/format.ts b/src/ui/format.ts index 9f23607..d99ed75 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -1,17 +1,62 @@ -/** Formats an ISO date as e.g. "Sunday, August 9, 2026" — always in UTC so - * it matches calendar/weekday.ts's interpretation regardless of the - * viewer's local timezone. */ -export function formatDateLong(isoDate: string): string { +// Latin month names have no Intl locale data, so the genitive forms below +// (used the same way English "of August" would be, e.g. "18 Augusti 2026") +// are authored directly here. +const MONTH_LABELS_LA = [ + 'Ianuarii', + 'Februarii', + 'Martii', + 'Aprilis', + 'Maii', + 'Iunii', + 'Iulii', + 'Augusti', + 'Septembris', + 'Octobris', + 'Novembris', + 'Decembris', +]; + +const WEEKDAY_LABELS_LA = ['Dominica', 'Feria Secunda', 'Feria Tertia', 'Feria Quarta', 'Feria Quinta', 'Feria Sexta', 'Sabbato']; + +/** Formats an ISO date as e.g. "Sunday, August 9, 2026" (`en`) / "Dominica, + * die 9 Augusti 2026" (`la`) — always in UTC so it matches + * calendar/weekday.ts's interpretation regardless of the viewer's local + * timezone. Weekday naming mirrors calendar/day-label.ts's own + * `WEEKDAY_LABELS` (spelled-out Latin ferial names, not roman numerals). */ +export function formatDateLong(isoDate: string): Partial> { const date = new Date(`${isoDate}T00:00:00Z`); - return new Intl.DateTimeFormat('en-US', { + const en = new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC', }).format(date); + + const day = date.getUTCDate(); + const month = MONTH_LABELS_LA[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const weekday = WEEKDAY_LABELS_LA[date.getUTCDay()]; + const la = `${weekday}, die ${day} ${month} ${year}`; + + return { en, la }; } export function hourLabel(hourId: string): string { return hourId.charAt(0).toUpperCase() + hourId.slice(1); } + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** Renders one `
` per currently-active language + * for a bilingual text map — the same pattern hour-view.ts's own + * `renderColumns` uses for resolved liturgical text, shared here so + * day-nav.ts's header can reuse it instead of re-implementing it. */ +export function renderBilingual(text: Partial>, languages: readonly string[], className: string): string { + const cols = languages + .map((lang) => `
${escapeHtml(text[lang] ?? '')}
`) + .join(''); + return `
${cols}
`; +} diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index 9076690..b578ca5 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -149,7 +149,6 @@ export function renderHourView(container: HTMLElement): void { container.innerHTML = `

${hourLabel(selectedHour)}

- ${ordo.dayLabel ? `

${escapeHtml(ordo.dayLabel)}

` : ''} ${ordo.parts.map((part) => renderPart(part, languages)).join('')}
`; diff --git a/src/ui/styles.css b/src/ui/styles.css index 3e1bfe9..7814d84 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -165,12 +165,26 @@ button:focus-visible { cursor: pointer; } -.day-nav-date { - font-weight: 600; +.day-nav-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; min-width: 16rem; text-align: center; } +.day-nav-date { + font-weight: 600; +} + +.day-nav-label { + font-family: var(--font-ui); + font-size: 0.85rem; + font-style: italic; + color: var(--color-brass); +} + /* -------------------------------------------------------------------------- Layout: hour list + hour view -------------------------------------------------------------------------- */ @@ -261,14 +275,6 @@ button:focus-visible { margin: 0 0 var(--space-1); } -.day-label { - font-family: var(--font-ui); - font-size: 0.9em; - font-style: italic; - color: var(--color-brass); - margin: 0 0 var(--space-2); -} - .ordo-part-citation { font-family: var(--font-ui); font-size: 0.85em; diff --git a/tests/calendar/christ-the-king.test.ts b/tests/calendar/christ-the-king.test.ts index 5889d64..0b1ffd4 100644 --- a/tests/calendar/christ-the-king.test.ts +++ b/tests/calendar/christ-the-king.test.ts @@ -16,7 +16,7 @@ describe('Christ the King (applyChristTheKing)', () => { // already carrying (Ss. Chrysanthus and Daria, Simplex, per the next // test below) -- this app's day label now surfaces every commemorated // saint, not just the Sunday itself. - expect(getDayLabel(day)).toBe('Christ the King (Duplex I Class) — Ss. Chrysanthus and Daria, Martyrs'); + expect(getDayLabel(day).en).toBe('Christ the King (Duplex I Class) — Ss. Chrysanthus and Daria, Martyrs'); }); it('preserves a nested commemoration: a Simplex saint already commemorated under the ordinary Sunday stays commemorated', () => { diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index a08e849..4b30aca 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -10,8 +10,11 @@ describe('getDayLabel — ordinal temporal label', () => { // the full-year sanctoral import), which would win outright and hide // this purely-temporal label case. 2027's Trinity Sunday is May 23, // so Trinity Monday is May 24 -- free of any sanctoral entry. - expect(getDayLabel(resolveDay('2027-05-24'))).toBe('Monday after Trinity Sunday (Feria)'); - expect(getDayLabel(resolveDay('2026-01-07'))).toBe('Wednesday after Epiphany (Feria)'); // Epiphany 2026 is a Tuesday + expect(getDayLabel(resolveDay('2027-05-24')).en).toBe('Monday after Trinity Sunday (Feria)'); + expect(getDayLabel(resolveDay('2026-01-07')).en).toBe('Wednesday after Epiphany (Feria)'); // Epiphany 2026 is a Tuesday + // Latin: spelled-out ferial weekday name (not roman numerals) and the + // "Feria" rank label, which is already the real Latin word. + expect(getDayLabel(resolveDay('2027-05-24')).la).toBe('Feria Secunda post Dominica Sanctissimae Trinitatis (Feria)'); }); it('names the anchor day itself, not "day after itself"', () => { @@ -27,11 +30,20 @@ describe('getDayLabel — ordinal temporal label', () => { // commemorated in return (`decideOccurrence`'s own `ordinary-sunday` // branch), by bare name only — a commemoration never carries a rank // parenthetical, only the day's own winner does. - expect(getDayLabel(resolveDay('2026-05-31'))).toBe( + expect(getDayLabel(resolveDay('2026-05-31')).en).toBe( 'The Queenship of the Blessed Virgin Mary (Duplex II Class) — Trinity Sunday', ); - expect(getDayLabel(resolveDay('2026-04-05'))).toBe('Easter (Duplex I Class)'); - expect(getDayLabel(resolveDay('2026-02-18'))).toBe('Ash Wednesday'); + expect(getDayLabel(resolveDay('2026-04-05')).en).toBe('Easter (Duplex I Class)'); + expect(getDayLabel(resolveDay('2026-02-18')).en).toBe('Ash Wednesday'); + // Latin: no authored Latin saint name exists yet, so the winner's name + // falls back to the same English string (see file header) — only the + // rank label ("Duplex II. Classis") and the commemorated Sunday's own + // name ("Dominica Sanctissimae Trinitatis") are real Latin. + expect(getDayLabel(resolveDay('2026-05-31')).la).toBe( + 'The Queenship of the Blessed Virgin Mary (Duplex II. Classis) — Dominica Sanctissimae Trinitatis', + ); + expect(getDayLabel(resolveDay('2026-04-05')).la).toBe('Pascha (Duplex I. Classis)'); + expect(getDayLabel(resolveDay('2026-02-18')).la).toBe('Feria Quarta Cinerum'); }); it('gives the correct week-after-Trinity ordinal', () => { @@ -40,7 +52,7 @@ describe('getDayLabel — ordinal temporal label', () => { // sanctoral import), which wins outright. 2027's Trinity Sunday is // May 23, so 5 full weeks after the first Sunday-after-Trinity // (May 30) is Jun 28 -- free of any sanctoral entry. - expect(getDayLabel(resolveDay('2027-06-28'))).toBe('Monday in the 5th week after Trinity (Feria)'); + expect(getDayLabel(resolveDay('2027-06-28')).en).toBe('Monday in the 5th week after Trinity (Feria)'); }); it("Advent's own anchor Sunday is already week 1, not a separate anchor-week case", () => { @@ -50,8 +62,15 @@ describe('getDayLabel — ordinal temporal label', () => { // reference engine, which shows the same pairing), so the label // reflects both, feast name first, same pattern as Trinity Sunday's // own St. Felix I case above. - expect(getDayLabel(resolveDay('2025-11-30'))).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); - expect(getDayLabel(resolveDay('2025-12-01'))).toBe('Monday in the 1st week of Advent (Feria)'); + expect(getDayLabel(resolveDay('2025-11-30')).en).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); + // Advent drops the redundant leading weekday word now that the header + // always shows the weekday on its own (see temporalLabel's + // `dropWeekdayPrefix`). + expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('In the 1st week of Advent (Feria)'); + // Latin: same weekday-drop, and the season-ordinal genitive + // construction ("Hebdomada prima Adventus") needs no extra linking + // word — 4th-declension "Adventus" has the same genitive form. + expect(getDayLabel(resolveDay('2025-12-01')).la).toBe('In hebdomada prima Adventus (Feria)'); }); it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => { @@ -59,7 +78,7 @@ describe('getDayLabel — ordinal temporal label', () => { // (Duplex, added per the full-year sanctoral import) -- moved to // May 21, still within Ascensiontide 2026 (May 14-23) and still // free of any sanctoral entry. - expect(getDayLabel(resolveDay('2026-05-21'))).toContain('in Ascensiontide'); + expect(getDayLabel(resolveDay('2026-05-21')).en).toContain('in Ascensiontide'); }); }); @@ -77,7 +96,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // Simplex commemoration (Ss. Tryphon, Respicius, and Nympha), shown // appended to the Sunday's own label per this app's usual // commemorated-Simplex display. - expect(getDayLabel(resolveDay('2024-11-10'))).toBe( + expect(getDayLabel(resolveDay('2024-11-10')).en).toBe( 'Ss. Tryphon, Respicius, and Nympha, Martyrs — The 5th Sunday after Epiphany (Semiduplex)', ); // 2024-11-11 is St. Martin of Tours (Duplex, pre-existing content) -- @@ -89,10 +108,10 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // overflow-week Monday instead (no single Nov-dated Monday near this // Sunday pair is clean any more, now that Nov 4/8/15/16/20 are all // fixed real content). - expect(getDayLabel(resolveDay('2024-11-11'))).toBe( + expect(getDayLabel(resolveDay('2024-11-11')).en).toBe( 'St. Martin of Tours, Bishop and Confessor (Duplex Majus) — St. Menna, Martyr', ); - expect(getDayLabel(resolveDay('2024-11-17'))).toBe('The 6th Sunday after Epiphany (Semiduplex)'); + expect(getDayLabel(resolveDay('2024-11-17')).en).toBe('The 6th Sunday after Epiphany (Semiduplex)'); // 2024-11-18 is the Dedication of the Basilicas of Ss. Peter and Paul // (pre-existing fixed content) -- again a real winning saint, not the // ferial fallback -- with St. Gregory Thaumaturgus (Semiduplex, @@ -100,14 +119,14 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // direct instruction, a Semiduplex feast on an ordinary Sunday // transfers to the next open day rather than being commemorated in // place, and Nov 18 is that day. - expect(getDayLabel(resolveDay('2024-11-18'))).toBe( + expect(getDayLabel(resolveDay('2024-11-18')).en).toBe( 'Dedication of the Basilicas of Ss. Peter and Paul (Duplex) — St. Gregory Thaumaturgus, Bishop and Confessor', ); // 2035-10-29, a Monday in the 3rd week of a different overflow // stretch, is genuinely clean (no sanctoral entry, no commemoration) // -- covers the ferial "Monday in the Nth week after Epiphany" format // itself, independent of the specific 5th/6th Sunday pair above. - expect(getDayLabel(resolveDay('2035-10-29'))).toBe('Monday in the 3rd week after Epiphany (Feria)'); + expect(getDayLabel(resolveDay('2035-10-29')).en).toBe('Monday in the 3rd week after Epiphany (Feria)'); }); it('the fixed final Sunday of the year always shows the fixed 23rd-after-Trinity ordinal, not a raw elapsed-week count', () => { @@ -122,8 +141,8 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // Thaumaturgus above -- landing on 2026-11-23 instead, where St. // Clement already natively wins; Cecilia loses that tied collision // (both Semiduplex) and joins St. Felicitas as a commemoration there. - expect(getDayLabel(resolveDay('2026-11-22'))).toBe('The 23rd Sunday after Trinity (Semiduplex)'); - expect(getDayLabel(resolveDay('2026-11-23'))).toBe( + expect(getDayLabel(resolveDay('2026-11-22')).en).toBe('The 23rd Sunday after Trinity (Semiduplex)'); + expect(getDayLabel(resolveDay('2026-11-23')).en).toBe( 'St. Clement I, Pope and Martyr (Semiduplex) — St. Felicitas, Martyr — St. Cecilia, Virgin and Martyr', ); // 1943-11-21 is also the Presentation of the BVM (Duplex Majus, added @@ -136,7 +155,7 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () => // additionally confirms the displaced Sunday is still commemorated in // return, using the same fixed 23rd-after-Trinity ordinal, not a raw // elapsed-week count recomputed for 1943. - expect(getDayLabel(resolveDay('1943-11-21'))).toBe( + expect(getDayLabel(resolveDay('1943-11-21')).en).toBe( 'The Presentation of the Blessed Virgin Mary (Duplex Majus) — The 23rd Sunday after Trinity', ); }); @@ -150,8 +169,10 @@ describe('getDayLabel — commemorated Sunday/feria under a sanctoral winner', ( // (`decideOccurrence`'s `privileged-feria-minor` branch) -- live- // verified shape, same mechanism as the ordinary-Sunday case above, // for the feria-tier branch instead. - expect(getDayLabel(resolveDay('2027-12-07'))).toBe( - 'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — Tuesday in the 2nd week of Advent', + // Advent drops the redundant leading weekday word (see the Dec 1 case + // above). + expect(getDayLabel(resolveDay('2027-12-07')).en).toBe( + 'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — In the 2nd week of Advent', ); }); }); @@ -162,7 +183,7 @@ describe('getDayLabel — active octave', () => { // Clare's own day -- Simplex, below the octave's semiduplex threshold, // so she's commemorated rather than winning. Matches the live engine's // own title for this date directly (see saints/st-clare.yml). - expect(getDayLabel(resolveDay('2026-08-12'))).toBe( + expect(getDayLabel(resolveDay('2026-08-12')).en).toBe( '3rd Day within the Octave of St. Lawrence, Martyr (Semiduplex) — St. Clare, Virgin', ); }); @@ -181,7 +202,7 @@ describe('getDayLabel — active octave', () => { // "real content closed the gap" shape as August's Lawrence/Assumption // overlap. Day 3 of the same octave (2028-11-03, a Friday) is still // genuinely open -- no sanctoral entry, no separate commemoration. - expect(getDayLabel(resolveDay('2028-11-03'))).toBe('3rd Day within the Octave of All Saints (Semiduplex)'); + expect(getDayLabel(resolveDay('2028-11-03')).en).toBe('3rd Day within the Octave of All Saints (Semiduplex)'); }); 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", () => { @@ -191,14 +212,14 @@ describe('getDayLabel — active octave', () => { // muddy this specifically octave-vs-anchor-day check with a sanctoral // winner. 2030's Trinity Sunday (June 16) has no sanctoral entry at // all, isolating the octave interaction cleanly. - expect(getDayLabel(resolveDay('2030-06-16'))).toBe('Trinity Sunday (Duplex I Class)'); + expect(getDayLabel(resolveDay('2030-06-16')).en).toBe('Trinity Sunday (Duplex I Class)'); }); 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 (Feria)'); + expect(getDayLabel(day).en).not.toContain('Octave'); + expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)'); }); 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 -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => { @@ -221,10 +242,10 @@ describe('getDayLabel — active octave', () => { // question): "Commemoratio: Tertia die infra Octavam S. Assumptionis // Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed // Virgin Mary". - expect(getDayLabel(resolveDay('2026-08-17'))).toBe( + expect(getDayLabel(resolveDay('2026-08-17')).en).toBe( 'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', ); - expect(getDayLabel(resolveDay('2026-08-18'))).toBe( + expect(getDayLabel(resolveDay('2026-08-18')).en).toBe( '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)', ); }); @@ -243,10 +264,10 @@ describe('getDayLabel — active octave', () => { // octave mentions alongside a sanctoral winner. const day = resolveDay('2026-08-16'); expect(day.weekday).toBe('sunday'); - expect(getDayLabel(day)).toBe( + expect(getDayLabel(day).en).toBe( 'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class) — The 11th Sunday after Trinity', ); - expect(getDayLabel(day)).not.toContain('Octave'); + expect(getDayLabel(day).en).not.toContain('Octave'); }); it("a foreign octave superimposed on a privileged season still governs the label when its own effective rank clears that season's real threshold, with the season's own losing saint commemorated alongside it", () => { @@ -266,7 +287,7 @@ describe('getDayLabel — active octave', () => { // mechanism (also used by hours/resolve-common.ts's // resolveOfficeWinner for the actual office content, not just this // label). - expect(getDayLabel(resolveDay('2026-12-15'))).toBe( + expect(getDayLabel(resolveDay('2026-12-15')).en).toBe( 'Octave of The Immaculate Conception of the Blessed Virgin Mary (Duplex) — St. Eusebius of Vercelli, Bishop and Martyr', ); }); @@ -280,7 +301,7 @@ describe('getDayLabel — active octave', () => { const day = resolveDay('2033-12-30'); expect(day.temporalCategory).toBe('privileged-feria-minor'); expect(day.season).toBe('christmastide'); - expect(getDayLabel(day)).not.toContain('Octave'); + expect(getDayLabel(day).en).not.toContain('Octave'); }); it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => { @@ -296,7 +317,7 @@ describe('getDayLabel — active octave', () => { // itself is what's being commemorated against a winning saint) and // also not `octaveLabel`'s own rank parenthetical (that belongs only // to an octave day when it's the day's own primary winner). - expect(getDayLabel(resolveDay('2026-08-19'))).toBe( + expect(getDayLabel(resolveDay('2026-08-19')).en).toBe( 'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary', ); }); @@ -311,7 +332,7 @@ describe('getDayLabel — named temporal feast rank', () => { // direct winner id) out of scope here. Pentecost Sunday's own id // *does* resolve as the direct winner (`temporal-id.ts`'s // EASTER_OFFSET_IDS), so it's a real, clean case for this branch. - expect(getDayLabel(resolveDay('2026-05-24'))).toBe('Pentecost (Duplex I Class)'); + expect(getDayLabel(resolveDay('2026-05-24')).en).toBe('Pentecost (Duplex I Class)'); }); }); @@ -330,7 +351,7 @@ describe('getDayLabel — feast name combination', () => { ...base, winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' }, }; - expect(getDayLabel(day)).toBe('St. Ereden (Duplex)'); + expect(getDayLabel(day).en).toBe('St. Ereden (Duplex)'); }); it('shows both, feast first, when the feast is merely commemorated', () => { @@ -338,10 +359,10 @@ describe('getDayLabel — feast name combination', () => { ...base, commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }], }; - expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)'); + expect(getDayLabel(day).en).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)'); }); it('shows just the temporal label when nothing is commemorated at all', () => { - expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity (Feria)'); + expect(getDayLabel(base).en).toBe('Monday in the 2nd week after Trinity (Feria)'); }); }); diff --git a/tests/calendar/marian-saturday.test.ts b/tests/calendar/marian-saturday.test.ts index ae0c71e..3bbefb7 100644 --- a/tests/calendar/marian-saturday.test.ts +++ b/tests/calendar/marian-saturday.test.ts @@ -17,7 +17,7 @@ describe('Marian Saturday (applyMarianSaturday)', () => { const day = resolveDay('2026-07-04'); expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' }); expect(day.commemorations).toEqual([]); - expect(getDayLabel(day)).toBe("Our Lady's Saturday (Simplex)"); + expect(getDayLabel(day).en).toBe("Our Lady's Saturday (Simplex)"); }); it('wins over a Simplex saint, who is commemorated instead of winning outright', () => { diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index c0cc04e..364360e 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -97,7 +97,7 @@ describe('resolveOrdo("compline", ...)', () => { const eve = resolveOrdo('compline', '2025-11-29'); const last = eve.parts[eve.parts.length - 1]; expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater'); - expect(eve.dayLabel).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); + expect(eve.dayLabel?.en).toBe('St. Andrew, Apostle — The 1st Sunday of Advent (Semiduplex)'); const dayBefore = resolveOrdo('compline', '2025-11-28'); const lastBefore = dayBefore.parts[dayBefore.parts.length - 1]; diff --git a/tests/ui/shell.test.ts b/tests/ui/shell.test.ts index 7b2a594..edb0a17 100644 --- a/tests/ui/shell.test.ts +++ b/tests/ui/shell.test.ts @@ -19,6 +19,10 @@ describe('shell', () => { mountShell(root); expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 9, 2026'); + // The "day being celebrated" text now lives in the merged day-nav + // header, not repeated inside each hour's own view. + expect(root.querySelector('.day-nav-label')?.textContent).toBeTruthy(); + expect(root.querySelector('.hour-view .day-label')).toBeNull(); const hourNames = Array.from(root.querySelectorAll('.hour-name')).map((el) => el.textContent); expect(hourNames).toEqual([