import { css, html, nothing, type TemplateResult } from 'lit'; import { kpFinanceCardEndpoints } from '../generated/endpoint-defaults.js'; import type { ChromeString } from '../i18n/chrome-strings.js'; import { planetGlyph } from '../tokens/index.js'; import type { GetKpDailyFinanceResponse } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { customElement } from '../utils/define.js'; import { formatDateTime, formatList, formatNumber, formatPercent, formatTime, formatTimeRange, } from '../utils/format.js'; import { frameCaptionStyles, renderFrameCaption } from '../utils/frame.js'; type Finance = GetKpDailyFinanceResponse; type Layers = Finance['layers']; type LayerKey = keyof Layers; type CuspRow = Layers['cusps']['rows'][number]; type DashaRow = Layers['dasha']['rows'][number]; type RulingRow = Layers['rulingPlanets']['rows'][number]; type WindowRow = Layers['moonWindows']['rows'][number]; type Verdict = CuspRow['verdict']; /** * The five bands the day score lands in, each beside the English source its label is looked up by. * * @remarks * Keyed by the response enum rather than a string built at the call site, so a band added upstream fails to typecheck here instead of rendering its raw wire value. */ const BAND_LABEL: Record = { strong: 'Strong', favourable: 'Favourable', mixed: 'Mixed', caution: 'Caution', unfavourable: 'Unfavourable', }; /** The one verdict every layer row carries, in the words the catalogue already holds for a planet state. */ const VERDICT_LABEL: Record = { favourable: 'Favourable', mixed: 'Mixed', unfavourable: 'Unfavourable', neutral: 'Neutral', }; /** The four layers in the order the response publishes them, which is the order the score weighs them. */ const LAYER_LABEL: Record = { cusps: 'Cusps', dasha: 'Dasha', rulingPlanets: 'Ruling planets', moonWindows: 'Moon windows', }; /** Which of the two house sets a row belongs to. */ const GROUP_LABEL: Record<'gain' | 'loss', ChromeString> = { gain: 'Gain', loss: 'Loss', }; /** The four Vimshottari levels the dasha layer can run. */ const LEVEL_LABEL: Record = { mahadasha: 'Mahadasha', antardasha: 'Antardasha', pratyantardasha: 'Pratyantardasha', sookshmaDasha: 'Sookshma', }; /** * KP daily finance. Renders POST /vedic-astrology/kp/daily-finance: one native, one civil day, four sub lord layers weighed into one score for money. * * @remarks * **The band is the answer and every number under it is the evidence.** The score is a weight-sum of four layers, each the mean of the rows printed in its own table, so a reader can redo the arithmetic from the card: the cusp sub lords of the gain and loss houses, the running Vimshottari lords at the reading moment, the ruling planets of that moment, and the sub lord windows the Moon passes through across the day. No meter, bar or percentage badge, for the same reason the Vedic daily card draws none: the scale is a weighed classification and not a share of anything, so a filled gauge would read an ordinary day as a bad one. * * **Every planet is judged against the two significator sets, and the sets come first.** A row is favourable when its planet signifies a gain house and no loss house, mixed when both, unfavourable when a loss house alone, neutral when neither, so the card prints the gain and loss houses the reading used, the planets that signify each, and the per-house evidence behind those two lists before any layer table, because that is what every verdict below is read against. A loss cusp inverts its verdict score and is marked as a loss house on its row. * * **Every clock time is the request timezone's wall clock, never the viewer's.** `readingAt` and the Moon windows arrive as naive local datetimes, so the shared formatters pin them and a reader in another zone sees the native's day, not a shifted one. The dasha boundaries are instants and follow the viewer. * * The card is data end to end and carries no written interpretation, so `hide-readings` leaves it whole. */ @customElement('roxy-kp-finance-card') export class RoxyKpFinanceCard extends RoxyDataElement { static endpoints = kpFinanceCardEndpoints; static styles = [ baseStyles, frameCaptionStyles, css` .wrap { background: var(--roxy-surface, #fff); color: var(--roxy-fg, #0a0a0a); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); box-shadow: var(--roxy-shadow-sm); display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; } .head { padding: var(--roxy-space-md, 1rem); display: grid; gap: var(--roxy-space-sm, 0.5rem); } .title-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); } .when { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } /* The band word leads and the number follows it: the band is the * reading of the day and the score is the evidence for it. */ .verdict-row { display: flex; align-items: center; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .verdict { font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); padding: 0.15rem 0.6rem; border-radius: var(--roxy-radius-full, 9999px); background: color-mix(in srgb, var(--roxy-muted, #71717a) 14%, transparent); } .verdict.strong, .verdict.favourable { background: color-mix(in srgb, var(--roxy-success, #16a34a) 16%, transparent); color: var(--_success-fg); } .verdict.mixed, .verdict.caution { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 18%, transparent); color: var(--roxy-accent-ink, #b45309); } .verdict.unfavourable { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 14%, transparent); color: var(--_danger-fg); } .evidence { color: var(--roxy-secondary, #475569); font-size: var(--roxy-text-sm, 0.875rem); font-variant-numeric: tabular-nums; } .block { padding: var(--roxy-space-md, 1rem); border-top: 1px solid var(--roxy-border, #e4e4e7); display: grid; gap: var(--roxy-space-sm, 0.5rem); } .block h3 { margin: 0; font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.06em; color: var(--roxy-muted, #71717a); } .block-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--roxy-space-sm, 0.5rem); margin: 0; } .fact { display: grid; gap: 2px; } .fact dt { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.05em; } .fact dd { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); } .chips { display: flex; flex-wrap: wrap; gap: 0.35rem; } .chip { font-size: var(--roxy-text-xs, 0.75rem); padding: 1px 8px; border-radius: var(--roxy-radius-full, 9999px); border: 1px solid var(--roxy-border, #e4e4e7); color: var(--roxy-secondary, #475569); } .scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; min-width: 0; } table { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); } th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--roxy-border, #e4e4e7); white-space: nowrap; } th { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.05em; font-weight: var(--roxy-weight-bold, 600); } td.num { font-variant-numeric: tabular-nums; } /* A list of names wraps in place: the table has two columns, so a * wrapped cell reads better than a scroll box on a phone. */ td.names { white-space: normal; } .glyph { margin-right: 0.35em; color: var(--roxy-muted, #71717a); } .retro { margin-left: 0.3em; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } /* One chip per verdict, tinted by what it means, the same three tints * the band above uses so a reader learns one scale for the card. */ .state { font-size: var(--roxy-text-xs, 0.75rem); padding: 1px 8px; border-radius: var(--roxy-radius-full, 9999px); background: color-mix(in srgb, var(--roxy-muted, #71717a) 14%, transparent); } .state.favourable { background: color-mix(in srgb, var(--roxy-success, #16a34a) 16%, transparent); color: var(--_success-fg); } .state.mixed { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 16%, transparent); color: var(--roxy-accent-ink, #b45309); } .state.unfavourable { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 14%, transparent); color: var(--_danger-fg); } .window { display: grid; gap: 1px; padding: 0.3rem 0.6rem; border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); font-size: var(--roxy-text-sm, 0.875rem); } .window small { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); font-variant-numeric: tabular-nums; } .window.best { border-color: color-mix(in srgb, var(--roxy-success, #16a34a) 45%, transparent); } .window.worst { border-color: color-mix(in srgb, var(--roxy-danger, #dc2626) 35%, transparent); } `, ]; protected renderData(d: Finance) { if (!d.layers) return this.renderEmpty(); return html`
${this.renderHead(d)} ${this.renderSignificators(d)}
${this.renderLayer('cusps', d.layers.cusps, this.renderCuspRows(d.layers.cusps.rows))}
${this.renderLayer('dasha', d.layers.dasha, this.renderDashaRows(d.layers.dasha.rows))}
${this.renderLayer( 'rulingPlanets', d.layers.rulingPlanets, this.renderRulingRows(d.layers.rulingPlanets.rows), )}
${this.renderLayer( 'moonWindows', d.layers.moonWindows, this.renderWindowRows(d.layers.moonWindows.rows), )}
${this.renderWindows(d)}
${renderFrameCaption( this.effectiveLang(), { ayanamsa: d.ayanamsa, ayanamsaDegrees: d.ayanamsaDegrees }, this.translator, )}
`; } /** The band, the score behind it, the day and the moment it was read at, and the two house sets. */ private renderHead(d: Finance) { const locale = this.effectiveLang(); return html`

${this.t('KP daily finance')}

${formatDateTime(locale, d.date)}
${this.t(BAND_LABEL[d.band])} ${formatNumber(locale, d.score, 1)}
${this.t('Reading moment')}
${formatDateTime(locale, d.readingAt)}
${this.t('Gain houses')}
${formatList(locale, d.houses.gain.map(String))}
${this.t('Loss houses')}
${formatList(locale, d.houses.loss.map(String))}
`; } /** * The two planet sets every verdict is judged against, and the per-house evidence they were built from. * * @remarks * A house lists its four tier significators strongest tier first, and a planet that qualifies at more than one tier is listed once per tier. It is named once here, at its strongest, because the row answers which planets signify the house and the two sets above are built from exactly that. */ private renderSignificators(d: Finance) { const s = d.significators; const locale = this.effectiveLang(); const chips = (planets: readonly string[]) => html`
${planets.map((p) => html`${this.glyphName(p)}`)}
`; return html`

${this.t('Significators')}

${this.t('Gain')}
${chips(s.gain)}
${this.t('Loss')}
${chips(s.loss)}
${ s.byHouse.length ? html`
${s.byHouse.map( (h) => html``, )}
${this.t('House')} ${this.t('Significators')}
${h.house} ${this.groupChip(h.group)} ${formatList(locale, [...new Set(h.significators)])}
` : nothing }
`; } /** One layer: its name, the weight it carries and the score it contributed, over the rows it was scored from. */ private renderLayer( key: LayerKey, layer: Layers[LayerKey], rows: TemplateResult, ) { const locale = this.effectiveLang(); return html`

${this.t(LAYER_LABEL[key])}

${this.t('Weight')} ${formatPercent(locale, layer.weight, 0)} ยท ${this.t('Score')} ${formatNumber(locale, layer.score, 1)}
${rows}
`; } private renderCuspRows(rows: readonly CuspRow[]) { return html` ${rows.map( (r) => html``, )}
${this.t('House')} ${this.t('Sub lord')} ${this.t('Star lord')} ${this.t('Sign lord')} ${this.t('Verdict')} ${this.t('Score')}
${r.house} ${this.groupChip(r.inverted ? 'loss' : 'gain')} ${this.glyphName(r.subLord)} ${this.glyphName(r.starLord)} ${this.glyphName(r.signLord)} ${this.verdictChip(r.verdict)} ${formatNumber(this.effectiveLang(), r.score, 0)}
`; } private renderDashaRows(rows: readonly DashaRow[]) { const locale = this.effectiveLang(); return html` ${rows.map( (r) => html``, )}
${this.t('Level')} ${this.t('Lord')} ${this.t('Start')} ${this.t('End')} ${this.t('Verdict')} ${this.t('Score')} ${this.t('Weight')}
${this.t(LEVEL_LABEL[r.level])} ${this.glyphName(r.lord, r.retrograde)} ${formatDateTime(locale, r.startDate)} ${formatDateTime(locale, r.endDate)} ${this.verdictChip(r.verdict)} ${formatNumber(locale, r.score, 0)} ${formatPercent(locale, r.weight, 0)}
`; } private renderRulingRows(rows: readonly RulingRow[]) { return html` ${rows.map( (r) => html``, )}
${this.t('Planet')} ${this.t('Verdict')} ${this.t('Score')}
${this.glyphName(r.planet, r.retrograde)} ${this.verdictChip(r.verdict)} ${formatNumber(this.effectiveLang(), r.score, 0)}
`; } private renderWindowRows(rows: readonly WindowRow[]) { const locale = this.effectiveLang(); return html` ${rows.map( (r) => html``, )}
${this.t('Start')} ${this.t('End')} ${this.t('Sub lord')} ${this.t('Verdict')} ${this.t('Score')}
${formatTime(locale, r.from)} ${formatTime(locale, r.to)} ${this.glyphName(r.subLord)} ${this.verdictChip(r.verdict)} ${formatNumber(locale, r.score, 0)}
`; } /** * The favourable window of the day and every unfavourable one, lifted out of the Moon table so a reader planning the day finds them without reading nine rows. * * @remarks * `bestWindow` is null on a day with no favourable window, which is a fact about the day and not a missing value, so the block prints the worst windows alone rather than an empty slot. */ private renderWindows(d: Finance) { const locale = this.effectiveLang(); const best = d.bestWindow; const worst = d.worstWindows ?? []; if (!best && !worst.length) return nothing; const window = (w: WindowRow, kind: 'best' | 'worst') => html` ${this.glyphName(w.subLord)} ${formatTimeRange(locale, { start: w.from, end: w.to })} `; return html`
${ best ? html`
${this.t('Best window')}
${window(best, 'best')}
` : nothing } ${ worst.length ? html`
${this.t('Worst windows')}
${worst.map((w) => window(w, 'worst'))}
` : nothing }
`; } /** * A planet with its glyph in front, and the retrograde mark after it where the row says so. * * @remarks * The mark is the symbol the ephemeris and transit tables draw, with the catalogued word as its accessible name rather than printed: printed after a name, an adjective has to agree with that name in the languages that inflect it, and a symbol does not. */ private glyphName(name: string, retrograde = false) { const glyph = planetGlyph(name); return html`${glyph ? html`` : nothing}${name}${ retrograde ? html`℞` : nothing }`; } private verdictChip(verdict: Verdict) { return html`${this.t(VERDICT_LABEL[verdict])}`; } /** Which of the two sets a house belongs to, beside its number. */ private groupChip(group: 'gain' | 'loss') { return html`${this.t(GROUP_LABEL[group])}`; } } declare global { interface HTMLElementTagNameMap { 'roxy-kp-finance-card': RoxyKpFinanceCard; } }