${headerLabel}
${d.profile?.name ? html`
${d.profile.name}
` : nothing}
${d.profile?.birthdate ? html`
${formatDate(this.effectiveLang(), d.profile.birthdate)}
` : nothing}
${d.summary && readings ? html`${d.summary}
` : nothing}
${
cores.length > 0
? html`
${cores.map(
([k, v]) => html`
${humanize(k)}
${v.number ?? ''}
${v.meaning?.title ? html`
${v.meaning.title}` : nothing}
${isMaster(v.number, v.type) ? html`
${this.t('Master')} ${v.number}` : nothing}
`,
)}
`
: nothing
}
${this.renderInterpretation(
cores.map(([k, v]) => ({
label: humanize(k),
aside: [
v.number != null ? `${v.number}` : '',
isMaster(v.number, v.type) ? 'master' : '',
v.hasKarmicDebt && v.karmicDebtNumber
? `karmic debt ${v.karmicDebtNumber}`
: '',
]
.filter(Boolean)
.join(' · '),
body: v.meaning?.description ?? '',
extra: this.renderReading(v.meaning, `numerology-core-${k}`),
})),
'numerology-cores',
'Core numbers',
)}
${
maturity
? html`
${this.attr(this.t('Maturity'), maturity.isActive ? this.t('Active') : this.t('Not yet active'))}
${this.attr(this.t('Current age'), maturity.currentAge)}
${this.attr(this.t('Activates'), maturity.activationRange)}
`
: nothing
}
${
profile
? html`
${this.t('Birth day profile')}
${
profile.title
? html`
${[profile.day ? `Day ${profile.day}` : '', profile.title].filter(Boolean).join(' · ')}
`
: nothing
}
${profile.description && readings ? html`${profile.description}
` : nothing}
${this.renderReading(profile, 'numerology-birth-day-profile')}
`
: nothing
}
${ins ? this.renderInsights(ins) : nothing}
${
lucky
? html`
${this.t('Lucky associations')}
${this.attr(this.t('Day'), lucky.day)}
${this.attr(this.t('Element'), lucky.element)}
${this.attr(this.t('Ruling planet'), lucky.rulingPlanet)}
${this.attr(this.t('Colors'), lucky.colors?.join(', '))}
${this.attr(this.t('Gemstones'), lucky.gemstones?.join(', '))}
${this.attr(this.t('Compatible'), lucky.compatibleNumbers?.join(', '))}
${this.attr(this.t('Incompatible'), lucky.incompatibleNumbers?.join(', '))}
`
: nothing
}
`;
}
/** Everything the chart carries beyond the six core numbers: the karmic analysis, the current year and month, the four pinnacles and their paired challenges, the name-derived numbers, and the letter analysis. */
private renderInsights(
ins: NonNullable,
) {
const lessons = ins.karmicLessons;
const debt = ins.karmicDebt;
const year = ins.personalYear;
const passion = ins.hiddenPassion;
const subconscious = ins.subconsciousSelf;
const letters = ins.nameLetters;
const present = Object.entries(lessons?.presentNumbers ?? {});
const readings = !this.hideReadings;
return html`${
lessons
? html`
${this.t('Karmic lessons')}
${
lessons.missingNumbers?.length
? html`
${lessons.missingNumbers.map((n) => html`${this.t('Missing')} ${n}`)}
`
: html`${this.t('No numbers are missing from the birth name.')}
`
}
${
present.length > 0
? html`
${present.map(([digit, count]) => html`${digit} x ${count}`)}
`
: nothing
}
${this.renderInterpretation(
(lessons.lessons ?? []).map((l) => ({
label: l.lesson ?? `Lesson ${l.number}`,
aside: l.number != null ? `${l.number}` : '',
body: l.description ?? '',
extra: l.howToOvercome
? html`${this.t('How to overcome')}. ${l.howToOvercome}
`
: nothing,
})),
'numerology-karmic-lessons',
'Lessons',
)}
`
: nothing
}${
debt?.hasKarmicDebt
? html`
${this.t('Karmic debt')}
${
debt.debtNumbers?.length
? html`
${debt.debtNumbers.map((n) => html`${this.t('Debt')} ${n}`)}
`
: nothing
}
${this.renderInterpretation(
(debt.meanings ?? []).map((m) => ({
label: `${this.t('Karmic debt')} ${m.number}`,
body: m.description ?? '',
extra: html`${
m.challenge
? html`${this.t('Challenge')}. ${m.challenge}
`
: nothing
}${
m.resolution
? html`${this.t('Resolution')}. ${m.resolution}
`
: nothing
}`,
})),
'numerology-karmic-debt',
'Debts',
)}
`
: nothing
}${
year
? html`
${this.t('Personal year')}
${[
year.personalYear != null ? `${year.personalYear}` : '',
year.theme ?? '',
year.cycle ?? '',
]
.filter(Boolean)
.join(' · ')}
${year.forecast && readings ? html`${year.forecast}
` : nothing}
${
readings
? this.renderLists(
['Opportunities', year.opportunities],
['Challenges', year.challenges],
)
: nothing
}
${year.advice && readings ? html`${year.advice}
` : nothing}
${
// The month and its theme name where the year sits now; `focus` is
// the sentence about it.
year.personalMonth
? html`
${[
year.personalMonth.personalMonth != null
? `${this.t('Personal month')} ${year.personalMonth.personalMonth}`
: this.t('Personal month'),
year.personalMonth.theme ?? '',
]
.filter(Boolean)
.join(' · ')}.
${readings ? (year.personalMonth.focus ?? '') : ''}
`
: nothing
}
`
: nothing
}${
// Both period sections are an accordion and nothing else, so hiding the
// readings takes the heading with it rather than leaving one over an
// empty block.
ins.pinnacles?.length && readings
? html`
${this.t('Pinnacles')}
${this.renderInterpretation(
ins.pinnacles.map((p) => ({
label: [
p.position != null ? `Pinnacle ${p.position}` : 'Pinnacle',
p.meaning?.title ?? '',
]
.filter(Boolean)
.join(' · '),
aside: [
p.number != null ? `${p.number}` : '',
ageRange(p.startAge, p.endAge),
]
.filter(Boolean)
.join(' · '),
body: p.meaning?.description ?? '',
extra: this.renderLists(
['Opportunities', p.meaning?.opportunities],
['Challenges', p.meaning?.challenges],
),
})),
'numerology-pinnacles',
'Life phases',
)}
`
: nothing
}${
ins.challenges?.length && readings
? html`
${this.t('Challenges')}
${this.renderInterpretation(
ins.challenges.map((c) => ({
label: [
c.position != null ? `Challenge ${c.position}` : 'Challenge',
c.meaning?.title ?? '',
]
.filter(Boolean)
.join(' · '),
aside: [
c.number != null ? `${c.number}` : '',
ageRange(c.startAge, c.endAge),
]
.filter(Boolean)
.join(' · '),
body: c.meaning?.description ?? '',
extra: html`${
c.meaning?.lesson
? html`${this.t('Lesson')}. ${c.meaning.lesson}
`
: nothing
}${
c.meaning?.howToOvercome
? html`${this.t('How to overcome')}. ${c.meaning.howToOvercome}
`
: nothing
}`,
})),
'numerology-challenges',
'Obstacle periods',
)}
`
: nothing
}${
passion || subconscious
? html`
${this.t('Name numbers')}
${
passion
? html`
${[
passion.number != null
? `Hidden passion ${passion.number}`
: 'Hidden passion',
passion.title ?? '',
]
.filter(Boolean)
.join(' · ')}
${passion.description && readings ? html`
${passion.description}
` : nothing}
${
typeof passion.count === 'number'
? html`
${`Appears ${passion.count} times in the name`}
`
: nothing
}
`
: nothing
}
${
subconscious
? html`
${[
subconscious.number != null
? `Subconscious self ${subconscious.number}`
: 'Subconscious self',
subconscious.title ?? '',
]
.filter(Boolean)
.join(' · ')}
${subconscious.description && readings ? html`
${subconscious.description}
` : nothing}
${
subconscious.uniqueNumbers?.length
? html`
${`Numbers present: ${subconscious.uniqueNumbers.join(', ')}`}
`
: nothing
}
`
: nothing
}
`
: nothing
}${
letters && readings
? html`
${this.t('Name letters')}
${this.renderInterpretation(
[
{
label: this.t('Cornerstone'),
aside: letterAside(
letters.cornerstone?.letter,
letters.cornerstone?.number,
),
body: letters.cornerstone?.meaning ?? '',
},
{
label: this.t('Capstone'),
aside: letterAside(
letters.capstone?.letter,
letters.capstone?.number,
),
body: letters.capstone?.meaning ?? '',
},
{
label: this.t('First vowel'),
aside: letterAside(letters.firstVowel?.letter),
body: letters.firstVowel?.meaning ?? '',
},
],
'numerology-name-letters',
'Letter analysis',
)}
`
: nothing
}`;
}
/** Hero row shared by every single-number view: the numeral, the label, the archetype, and the master-number badge when the number is 11, 22, or 33. */
private renderHero(
headerLabel: string,
num: number | undefined,
title: string | undefined,
master = false,
) {
return html`
${typeof num === 'number' ? html`
${num}
` : nothing}
${headerLabel}
${title ? html`
${title}
` : nothing}
${master ? html`
${this.t('Master number')}` : nothing}
`;
}
/**
* Keyword chips, the strengths and challenges lists, and the three life-area readings behind a disclosure. The whole interpretation, laid out as a reading.
*
* Every part of it is prose or the shorthand of prose, so `hide-readings` drops the block whole rather than gating three pieces.
*/
private renderReading(r: NumerologyReading | undefined, name: string) {
if (!r || this.hideReadings) return nothing;
const sections: InterpSection[] = GUIDANCE_FIELDS.map(([key, label]) => ({
label,
body: (r[key] as string | undefined) ?? '',
}));
return html`${
r.keywords?.length
? html`${r.keywords.map((k) => html`${k}`)}
`
: nothing
}${this.renderLists(['Strengths', r.strengths], ['Challenges', r.challenges])}${this.renderInterpretation(sections, name, 'Guidance')}`;
}
/**
* Side-by-side prose lists. Each entry names a trait and explains it, so they are bulleted paragraphs, never chips.
*
* @remarks
* `h3`, not `h4`. This renders at the TOP level of the single-number card, where the only heading above it is the `h2` title, so an `h4` skips a level and fails the axe `heading-order` rule. Inside a chart section or an accordion body the heading above is already an `h3`, and a repeated level is fine: the rule flags skips, not repeats.
*/
private renderLists(
...lists: Array<[ChromeString, ReadonlyArray | undefined]>
) {
const shown = lists.filter(([, items]) => items && items.length > 0);
if (shown.length === 0) return nothing;
return html`
${shown.map(
([label, items]) => html`
${this.t(label)}
${(items ?? []).map((item) => html`- ${item}
`)}
`,
)}
`;
}
private attr(label: string, value: string | number | undefined) {
if (value === undefined || value === null || value === '') return nothing;
return html`${label}${value}`;
}
}
const LABELS: Record = {
'life-path': 'Life Path',
expression: 'Expression',
'soul-urge': 'Soul Urge',
personality: 'Personality',
'birth-day': 'Birth Day',
maturity: 'Maturity',
daily: 'Daily Number',
'personal-day': 'Personal Day',
'personal-month': 'Personal month',
'personal-year': 'Personal year',
chart: 'Numerology chart',
};
type KarmicDebtMeaning = CalculateLifePathResponse['karmicDebtMeaning'];
function karmicDebtText(value: KarmicDebtMeaning | undefined): string {
if (!value) return '';
return [value.description, value.challenge, value.resolution]
.filter(Boolean)
.join(' ');
}
/** A number is master when the API says so, or when it is one of the three that are never reduced. Both checks, because the period cycles carry the number without the `type` flag. */
function isMaster(num: number | undefined, type?: string): boolean {
return type === 'master' || (num !== undefined && MASTER_NUMBERS.has(num));
}
/** "5 · Freedom and change", the cycle a smaller cycle sits inside. Empty when the API sent neither half. */
function joinCycle(num: number | undefined, theme: string | undefined): string {
return [num != null ? `${num}` : '', theme ?? ''].filter(Boolean).join(' · ');
}
/** "ages 27 to 35", or "ages 54 onward" for the final phase, which the API closes with a null end age. */
function ageRange(
start: number | null | undefined,
end: number | null | undefined,
): string {
if (start == null) return '';
return end == null ? `ages ${start} onward` : `ages ${start} to ${end}`;
}
/** "A · 1" for a letter that carries a Pythagorean value, "A" for the first vowel, which does not. */
function letterAside(letter?: string, num?: number): string {
return [letter ?? '', num != null ? `${num}` : '']
.filter(Boolean)
.join(' · ');
}
declare global {
interface HTMLElementTagNameMap {
'roxy-numerology-card': RoxyNumerologyCard;
}
}