import { css, html, nothing, svg } from 'lit'; import { property } from 'lit/decorators.js'; import { biorhythmChartEndpoints } from '../generated/endpoint-defaults.js'; import type { ChromeString } from '../i18n/chrome-strings.js'; import type { GetCriticalDaysResponse, GetDailyBiorhythmResponse, GetForecastResponse, } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { BIORHYTHM_CYCLES, CYCLE_LABEL, cycleName, } from '../utils/biorhythm.js'; import { customElement } from '../utils/define.js'; import { chevron, disclosureStyles } from '../utils/disclosure.js'; import { formatDate, formatDateRange, formatList, formatNumber, formatPercent, } from '../utils/format.js'; import { type InterpSection, interpAccordionStyles, } from '../utils/interp-accordion.js'; import { humanize } from '../utils/string.js'; type BiorhythmData = | GetDailyBiorhythmResponse | GetForecastResponse | GetCriticalDaysResponse; const CYCLE_COLOR: Record = { physical: '#dc2626', emotional: '#0284c7', intellectual: '#16a34a', intuitive: '#a855f7', aesthetic: '#f59e0b', awareness: '#ec4899', spiritual: '#14b8a6', passion: '#ef4444', mastery: '#6366f1', wisdom: '#475569', }; /** A critical day `direction` to the English SOURCE of the line that says which way the cycle crossed zero. */ const CROSSING_LABEL: Record< GetCriticalDaysResponse['criticalDays'][number]['direction'], ChromeString > = { ascending: 'ascending through zero', descending: 'descending through zero', }; /** A critical day `severity` to the English SOURCE of its label; a `single` day, one cycle crossing zero, is what every row already says, so it adds no word. */ const SEVERITY_LABEL: Partial< Record< GetCriticalDaysResponse['criticalDays'][number]['severity'], ChromeString > > = { double: 'Double day', triple: 'Triple day', }; /** * Biorhythm chart. Renders /biorhythm/{daily,forecast,critical-days}. */ @customElement('roxy-biorhythm-chart') export class RoxyBiorhythmChart extends RoxyDataElement { static endpoints = biorhythmChartEndpoints; static styles = [ baseStyles, disclosureStyles, interpAccordionStyles, 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); padding: var(--roxy-space-lg, 1.5rem); box-shadow: var(--roxy-shadow-sm); display: grid; /* Never an implicit auto column: it floors at min-content, so one long * unbreakable string widens the track past the padded card. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); } .head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--roxy-space-sm, 0.5rem); } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); } .head-meta { display: flex; align-items: center; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .energy { font-variant-numeric: tabular-nums; color: var(--roxy-accent-ink, #b45309); font-weight: var(--roxy-weight-bold, 600); } /* The tint carries the phase; the label stays --roxy-fg, since muted or * accent ink on a tinted chip misses WCAG AA. */ .phase { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 16%, transparent); color: var(--roxy-fg, #0a0a0a); border-radius: var(--roxy-radius-full, 9999px); padding: 2px 10px; font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); } .spotlight { display: grid; gap: var(--roxy-space-xs, 0.25rem); padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); border-left: 3px solid var(--roxy-accent, #f59e0b); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 22%, transparent); border-radius: 0 var(--roxy-radius-sm, 4px) var(--roxy-radius-sm, 4px) 0; } .spotlight .label { margin: 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.06em; } .spotlight .lead { display: flex; align-items: baseline; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .spotlight strong { text-transform: capitalize; } .spotlight p { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); line-height: 1.6; } .bars { display: grid; gap: var(--roxy-space-xs, 0.25rem); } /* Not --roxy-label-col: the cycle name (physical, emotional, intellectual...) * is fixed English in every locale; capping it clips "intellectual" instead of * fixing a translation-driven growth that never happens here. */ .bar { display: grid; grid-template-columns: 8rem 1fr 3.5rem; gap: var(--roxy-space-sm, 0.5rem); align-items: center; font-size: var(--roxy-text-sm, 0.875rem); } /* A cycle runs from -100 to +100 percent, so the track is bipolar: a * zero line at the centre and the fill growing from it to the right for a * high and to the left for a low, which is what the sign of the number * beside it means. */ .track { height: 14px; background: var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-full, 9999px); overflow: hidden; position: relative; } .track::after { content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--roxy-surface, #fff); } .fill { position: absolute; top: 0; height: 100%; transition: width var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1)); } .fill.high { left: 50%; } .fill.low { right: 50%; } .value { font-variant-numeric: tabular-nums; text-align: right; color: var(--roxy-muted, #71717a); } .advice { color: var(--roxy-fg, #0a0a0a); margin: 0; } svg { display: block; width: 100%; max-width: var(--roxy-chart-max-width, 600px); height: auto; } .legend { display: flex; flex-wrap: wrap; gap: var(--roxy-space-md, 1rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-fg, #0a0a0a); } .key { display: inline-flex; align-items: center; gap: 0.35rem; text-transform: capitalize; } .dot { width: 0.6rem; height: 0.6rem; border-radius: var(--roxy-radius-full, 9999px); } .dot.critical { background: var(--roxy-danger, #dc2626); } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: var(--roxy-space-sm, 0.5rem); margin: 0; } .stat { display: grid; gap: 2px; } .stat dt { font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.05em; } .stat dd { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); font-variant-numeric: tabular-nums; } .forecast-data > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--roxy-space-sm, 0.5rem); cursor: pointer; } .forecast-scroll { overflow-x: auto; min-width: 0; margin-top: var(--roxy-space-sm, 0.5rem); } .forecast-table { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); font-variant-numeric: tabular-nums; } .forecast-table th, .forecast-table td { padding: var(--roxy-space-xs, 0.25rem) var(--roxy-space-sm, 0.5rem); text-align: right; border-bottom: 1px solid var(--roxy-border, #e4e4e7); white-space: nowrap; } .forecast-table th:first-child, .forecast-table td:first-child { text-align: left; } .forecast-axis { width: 100%; max-width: var(--roxy-chart-max-width, 600px); display: flex; justify-content: space-between; gap: var(--roxy-space-sm, 0.5rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-secondary, #475569); } .crit-note { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); background: color-mix(in srgb, var(--roxy-danger, #dc2626) 10%, transparent); border-radius: var(--roxy-radius-sm, 4px); padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); } .crit-meta { margin: 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); } `, ]; @property({ type: String, reflect: true }) mode: 'daily' | 'forecast' | 'critical-days' = 'daily'; protected renderData(d: BiorhythmData) { if (this.mode === 'critical-days' && 'criticalDays' in d) { return this.renderCritical(d as GetCriticalDaysResponse); } if (this.mode === 'forecast' && 'days' in d) { return this.renderForecast(d as GetForecastResponse); } return this.renderDaily(d as GetDailyBiorhythmResponse); } private renderDaily(d: GetDailyBiorhythmResponse) { const raw = d.quickRead ?? {}; const entries = Object.entries(raw).map(([cycle, value]) => { const v = typeof value === 'number' ? value : 0; const normalized = Math.abs(v) > 1 ? v / 100 : v; return [cycle, normalized] as const; }); const spot = d.spotlight; return html`

${this.t('Biorhythm')}

${d.overallPhase ? html`${humanize(d.overallPhase)}` : nothing} ${ typeof d.energyRating === 'number' ? html`${this.t('Energy {{value}}/10', { value: d.energyRating })}` : nothing }
${ spot ? html`

${this.t('Spotlight cycle')}

${cycleName(spot.cycle, (l) => this.t(l))} ${typeof spot.value === 'number' ? html`${formatPercent(this.effectiveLang(), spot.value, 0)}` : nothing} ${spot.phase ? html`${humanize(spot.phase)}` : nothing}
${ // The cycle, its value and its phase are the reading's subject; // the message is the reading. spot.message && !this.hideReadings ? html`

${spot.message}

` : nothing }
` : nothing }
${entries.map(([cycle, v]) => { // -1..1 -> 0..50 percent of the track, out from the zero line. const pct = Math.abs(v) * 50; const color = CYCLE_COLOR[cycle] ?? 'var(--roxy-accent, #f59e0b)'; return html`
${cycleName(cycle, (l) => this.t(l))} ${formatPercent(this.effectiveLang(), v * 100, 0)}
`; })}
${ this.hideReadings ? nothing : html`${d.dailyMessage ? html`

${d.dailyMessage}

` : nothing} ${d.advice ? html`

${d.advice}

` : nothing}` }
`; } private renderForecast(d: GetForecastResponse) { const days = d.days ?? []; if (days.length === 0) return html`
${this.t('No forecast')}
`; const w = 600; const h = 160; const xStep = w / Math.max(days.length - 1, 1); const s = d.summary; return html`

${this.t('Forecast')}

${formatDateRange(this.effectiveLang(), d.startDate, d.endDate)}
${this.t('Biorhythm forecast')} ${BIORHYTHM_CYCLES.map((cycle) => { const points = days .map((day, i) => { const v = day[cycle] ?? 0; const x = i * xStep; const y = h / 2 - (v / 100) * (h / 2 - 8); // Path coordinates, not copy: a decimal comma here IS the // separator and the polyline collapses. return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(' '); const color = CYCLE_COLOR[cycle] ?? '#475569'; return svg``; })} ${days.map((day, i) => // A critical day is a zero crossing, so it sits on the midline by // definition: mark it there rather than hiding it in the curves. day.isCritical ? svg`${[ formatDate(this.effectiveLang(), day.date), formatList( this.effectiveLang(), (day.criticalCycles ?? []).map((c) => cycleName(c, (l) => this.t(l)), ), ), this.t('critical day'), typeof day.energyRating === 'number' ? this.t('Energy {{value}}/10', { value: day.energyRating }) : '', ] .filter(Boolean) .join(' · ')}` : nothing, )}
${formatDate(this.effectiveLang(), days[0]?.date)}${formatDate(this.effectiveLang(), days.at(-1)?.date)}
${BIORHYTHM_CYCLES.map( (cycle) => html` ${this.t(CYCLE_LABEL[cycle])} `, )} ${this.t('critical day')}
${ s ? html`
${this.stat(this.t('Best day'), formatDate(this.effectiveLang(), s.bestDay))} ${this.stat(this.t('Worst day'), formatDate(this.effectiveLang(), s.worstDay))} ${this.stat( this.t('Average energy'), typeof s.averageEnergy === 'number' ? `${s.averageEnergy}/10` : '', )} ${this.stat( this.t('Critical days'), typeof s.criticalDayCount === 'number' ? `${s.criticalDayCount}` : '', )}
` : nothing } ${this.renderForecastData(d)} ${ s?.periodAdvice && !this.hideReadings ? html`

${s.periodAdvice}

` : nothing }
`; } private renderForecastData(d: GetForecastResponse) { const locale = this.effectiveLang(); return html`
${this.t('Daily values')}${chevron()}
${BIORHYTHM_CYCLES.map((cycle) => html``)} ${d.days.map( (day) => html` ${BIORHYTHM_CYCLES.map((cycle) => html``)} `, )}
${this.t('Biorhythm forecast')}
${this.t('Date')}${this.t(CYCLE_LABEL[cycle])}${this.t('Energy rating')} ${this.t('Days since birth')} ${this.t('Critical days')}
${formatPercent(locale, day[cycle], 2)}${this.t('Energy {{value}}/10', { value: day.energyRating })} ${formatNumber(locale, day.daysSinceBirth, 0)} ${ day.isCritical ? formatList( locale, day.criticalCycles.map((cycle) => cycleName(cycle, (source) => this.t(source)), ), ) || this.t('critical day') : this.t('None') }
`; } private renderCritical(d: GetCriticalDaysResponse) { const days = d.criticalDays ?? []; const doubles = d.doubleCriticalDays ?? []; const sections: InterpSection[] = days.map((day) => ({ label: formatDate(this.effectiveLang(), day.date) || day.date, aside: [ day.cycle ? cycleName(day.cycle, (l) => this.t(l)) : '', this.severityName(day.severity), ] .filter(Boolean) .join(' · '), body: day.advisory ?? '', extra: html`

${[ day.direction ? this.t(CROSSING_LABEL[day.direction]) : '', typeof day.period === 'number' ? this.t('{{period}}-day cycle', { period: day.period }) : '', ] .filter(Boolean) .join(' · ')}

`, })); return html`

${this.t('Critical days')}

${formatDateRange(this.effectiveLang(), d.startDate, d.endDate)}
${this.stat(this.t('Events'), typeof d.totalCriticalDays === 'number' ? `${d.totalCriticalDays}` : '')} ${this.stat(this.t('Double days'), doubles.length ? `${doubles.length}` : '0')} ${this.stat( this.t('Triple day'), d.tripleCriticalDay ? formatDate(this.effectiveLang(), d.tripleCriticalDay) : this.t('None'), )}
${ doubles.length > 0 ? html`

${this.t( 'Two or more cycles cross zero on {{dates}}. Take extra care on these dates.', { dates: formatList( this.effectiveLang(), doubles.map((x) => formatDate(this.effectiveLang(), x) || x), ), }, )}

` : nothing } ${this.renderInterpretation(sections, 'biorhythm-critical', 'Advisories')}
`; } /** A critical day severity as a reader sees it, empty for the base case. */ private severityName( severity: GetCriticalDaysResponse['criticalDays'][number]['severity'], ): string { const label = SEVERITY_LABEL[severity]; return label ? this.t(label) : ''; } private stat(label: string, value: string) { if (!value) return nothing; return html`
${label}
${value}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-biorhythm-chart': RoxyBiorhythmChart; } }