Files
vu/src/ui/hour-view.ts
T
will 7ffb9afea2 Extend closing Gloria Patri to canticles, centralize instead of baking it in
Benedictus, Magnificat, and Nunc Dimittis had the Gloria Patri baked
verbatim into their propers YAML with no Triduum suppression; Matins'
Nocturn 3 OT canticles had none at all. Give the 'canticle' ResolvedPart
the same centrally-attached gloriaPatri field psalms already got, strip
the baked-in text, and drop Lauds' now-redundant local append/Triduum
check — live-verified that canticles follow the exact same Triduum-only
omission as psalms (e.g. Benedictus at Lauds of Holy Thursday).
2026-08-19 06:06:07 -04:00

160 lines
6.1 KiB
TypeScript

import { getState } from '../app-state';
import { resolveOrdo } from '../hours';
import type { ResolvedPart, ResolvedText, ResolvedVerse } from '../hours/types';
import { hourLabel } from './format';
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function cellFor(resolved: ResolvedText | ResolvedVerse, lang: string): string {
const status = resolved.status[lang] ?? 'missing';
const text = resolved.text[lang];
if (status === 'missing' || !text) {
return '<span class="text-pending">(translation pending)</span>';
}
const escaped = escapeHtml(text);
return status === 'draft' ? `<span class="text-draft" title="unverified draft text">${escaped}</span>` : escaped;
}
function renderColumns(resolved: ResolvedText | ResolvedVerse, languages: readonly string[]): string {
const cols = languages
.map((lang) => `<div class="lang-column" lang="${lang}">${cellFor(resolved, lang)}</div>`)
.join('');
return `<div class="lang-columns lang-columns-${languages.length}">${cols}</div>`;
}
// Scripture quotations always carry a full citation (book, chapter, verse) —
// shown once, deduplicated across languages when they happen to match.
function renderCitation(text: ResolvedText): string {
if (!text.citation) {
return '';
}
const values = [...new Set(Object.values(text.citation).filter((v): v is string => Boolean(v)))];
return values.length ? `<p class="ordo-part-citation">${escapeHtml(values.join(' / '))}</p>` : '';
}
function renderPart(part: ResolvedPart, languages: readonly string[]): string {
switch (part.kind) {
case 'hymn':
case 'chapter':
case 'prayer':
return `
<section class="ordo-part ordo-part-${part.kind}">
<h3 class="ordo-part-label">${hourLabel(part.kind)}</h3>
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
</section>
`;
// Short liturgical dialogue — reads fine without a jargon label.
case 'responsory':
case 'versicle':
return `
<section class="ordo-part ordo-part-${part.kind}">
${renderColumns(part.text, languages)}
</section>
`;
case 'antiphon':
return `
<section class="ordo-part ordo-part-antiphon">
${renderColumns(part.text, languages)}
</section>
`;
case 'preces':
return `
<section class="ordo-part ordo-part-preces">
${part.label ? `<h3 class="ordo-part-label">${escapeHtml(part.label)}</h3>` : ''}
${renderColumns(part.text, languages)}
</section>
`;
case 'lesson':
return `
<section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}">
<h3 class="ordo-part-label">${part.isGospel ? 'Gospel' : part.label ? escapeHtml(part.label) : 'Reading'}</h3>
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
</section>
`;
case 'psalm': {
// Always cite the full range, not just the psalm number — a 4-verse
// slice of Psalm 118 is a different citation from the whole psalm.
const first = part.verses[0]?.n;
const last = part.verses[part.verses.length - 1]?.n;
const citation =
first === undefined
? `Psalm ${part.psalmNumber}`
: first === last
? `Psalm ${part.psalmNumber}:${first}`
: `Psalm ${part.psalmNumber}:${first}-${last}`;
return `
<section class="ordo-part ordo-part-psalm">
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
<h3 class="ordo-part-label">${citation}</h3>
<ol class="psalm-verses">
${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')}
</ol>
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section>
`;
}
case 'canticle': {
const title = part.canticleId
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return `
<section class="ordo-part ordo-part-canticle">
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
<h3 class="ordo-part-label">${escapeHtml(title)}</h3>
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section>
`;
}
// Matins only. Not currently emitted by hours/matins.ts (its psalms
// render as a flat sequence of plain 'psalm'/'antiphon'/'versicle'
// parts instead) — handled here for the ResolvedPart union's sake,
// ready for whenever nocturn-grouped UI treatment is wanted.
case 'nocturn-psalmody':
return `
<section class="ordo-part ordo-part-nocturn-psalmody">
<h3 class="ordo-part-label">Nocturn ${part.nocturn}</h3>
${part.psalms.map((p) => renderPart(p, languages)).join('')}
</section>
`;
case 'section-heading':
return `<h2 class="section-heading">${escapeHtml(part.label)}</h2>`;
case 'te-deum':
return `
<section class="ordo-part ordo-part-te-deum">
<h3 class="ordo-part-label">Te Deum</h3>
${renderColumns(part.text, languages)}
</section>
`;
}
}
export function renderHourView(container: HTMLElement): void {
const { date, selectedHour, languages } = getState();
const ordo = resolveOrdo(selectedHour, date);
if (ordo.notImplemented) {
container.innerHTML = `
<div class="hour-view hour-view-pending">
<h2>${hourLabel(selectedHour)}</h2>
<p>This hour hasn't been built yet.</p>
</div>
`;
return;
}
container.innerHTML = `
<div class="hour-view">
<h2>${hourLabel(selectedHour)}</h2>
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
</div>
`;
}