import { css, html, nothing, svg } from 'lit'; import { customElement, property } from 'lit/decorators.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 { disclosureStyles } from '../utils/disclosure.js'; import { formatDate, formatDateRange, 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', }; /** The cycles a forecast day carries, in the order they are plotted and keyed in the legend. */ const FORECAST_CYCLES = [ 'physical', 'emotional', 'intellectual', 'intuitive', ] as const; /** Response key to the English SOURCE its legend entry is looked up by. The key indexes the payload and stays lower case; the label a reader sees does not. */ const CYCLE_LABEL: Record<(typeof FORECAST_CYCLES)[number], ChromeString> = { physical: 'Physical', emotional: 'Emotional', intellectual: 'Intellectual', intuitive: 'Intuitive', }; /** * Biorhythm chart. Renders /biorhythm/{daily,forecast,critical-days}. */ @customElement('roxy-biorhythm-chart') export class RoxyBiorhythmChart extends RoxyDataElement { 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); } .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); } .track { height: 14px; background: var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-full, 9999px); overflow: hidden; position: relative; } .fill { display: block; height: 100%; transition: width var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1)); } .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; } .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')}

${humanize(spot.cycle)} ${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]) => { const pct = ((v + 1) / 2) * 100; // -1..1 -> 0..100 const color = CYCLE_COLOR[cycle] ?? 'var(--roxy-accent, #f59e0b)'; return html`
${cycle} ${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')} ${FORECAST_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), day.criticalCycles?.length ? `${day.criticalCycles.join(', ')} critical` : 'critical', typeof day.energyRating === 'number' ? `energy ${day.energyRating}/10` : '', ] .filter(Boolean) .join(' · ')}` : nothing, )}
${FORECAST_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 } ${ s?.periodAdvice && !this.hideReadings ? html`

${s.periodAdvice}

` : nothing }
`; } 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, day.severity].filter(Boolean).join(' · '), body: day.advisory ?? '', extra: html`

${[ day.direction ? `${day.direction} through zero` : '', typeof day.period === 'number' ? `${day.period} day cycle` : '', ] .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) : 'None in range', )}
${ doubles.length > 0 ? html`

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

` : nothing } ${this.renderInterpretation(sections, 'biorhythm-critical', 'Advisories')}
`; } private stat(label: string, value: string) { if (!value) return nothing; return html`
${label}
${value}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-biorhythm-chart': RoxyBiorhythmChart; } }