import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client'; import { Panel } from './Panel'; import { t, getLocale } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; let _client: MarketServiceClient | null = null; async function getMarketClient(): Promise { if (!_client) { const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client'); const { getRpcBaseUrl } = await import('@/services/rpc-client'); _client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); } return _client; } interface EarningsEntry { symbol: string; company: string; date: string; hour: string; epsEstimate: number | null; revenueEstimate: number | null; epsActual: number | null; revenueActual: number | null; hasActuals: boolean; surpriseDirection: string; } function fmtEps(v: number | null): string { if (v == null) return ''; const sign = v >= 0 ? '+' : ''; return `${sign}${v.toFixed(2)}`; } function fmtRevenue(v: number | null): string { if (v == null || v <= 0) return ''; if (v >= 1e12) return `$${(v / 1e12).toFixed(1)}T`; if (v >= 1e9) return `$${(v / 1e9).toFixed(1)}B`; if (v >= 1e6) return `$${Math.round(v / 1e6)}M`; return `$${v}`; } function surprisePct(actual: number | null, estimate: number | null): string { if (actual == null || estimate == null || estimate === 0) return ''; const pct = ((actual - estimate) / Math.abs(estimate)) * 100; const sign = pct >= 0 ? '+' : ''; return `${sign}${pct.toFixed(1)}%`; } function dateLabel(dateStr: string): string { const today = new Date(); today.setHours(0, 0, 0, 0); const d = new Date(`${dateStr}T00:00:00`); if (Number.isNaN(d.getTime())) return dateStr; const days = Math.round((d.getTime() - today.getTime()) / 86_400_000); const formatted = d.toLocaleDateString(getLocale(), { weekday: 'short', month: 'short', day: 'numeric' }); if (days === 0) return t('components.earningsCalendar.today', { date: formatted }); if (days === 1) return t('components.earningsCalendar.tomorrow', { date: formatted }); return formatted.toUpperCase().replace(',', ' ยท'); } function renderEntry(e: EarningsEntry): string { const hourLabel = e.hour === 'bmo' ? 'BMO' : e.hour === 'amc' ? 'AMC' : e.hour ? e.hour.toUpperCase() : ''; const hourStyle = e.hour === 'bmo' ? 'background:rgba(46,204,113,0.15);color:#2ecc71' : e.hour === 'amc' ? 'background:rgba(52,152,219,0.15);color:#3498db' : 'background:rgba(255,255,255,0.08);color:var(--text-dim)'; const revEstFmt = fmtRevenue(e.revenueEstimate); const revActFmt = fmtRevenue(e.revenueActual); const epsEstFmt = fmtEps(e.epsEstimate); const epsActFmt = fmtEps(e.epsActual); // EPS section: show actual+badge if reported, else estimate let epsHtml = ''; if (e.hasActuals && epsActFmt) { const badgeStyle = e.surpriseDirection === 'beat' ? 'background:rgba(46,204,113,0.2);color:#2ecc71' : e.surpriseDirection === 'miss' ? 'background:rgba(231,76,60,0.2);color:#e74c3c' : 'background:rgba(255,255,255,0.08);color:var(--text-dim)'; const badgeLabel = e.surpriseDirection === 'beat' ? t('components.earningsCalendar.surprise.beat') : e.surpriseDirection === 'miss' ? t('components.earningsCalendar.surprise.miss') : t('components.earningsCalendar.surprise.inLine'); const pct = surprisePct(e.epsActual, e.epsEstimate); epsHtml = ` ${escapeHtml(t('components.earningsCalendar.epsActual', { value: epsActFmt }))} ${escapeHtml(badgeLabel)}${pct ? ` ${escapeHtml(pct)}` : ''}`; } else if (epsEstFmt) { epsHtml = `${escapeHtml(t('components.earningsCalendar.epsEstimate', { value: epsEstFmt }))}`; } // Revenue section let revHtml = ''; if (e.hasActuals && revActFmt) { revHtml = `${escapeHtml(t('components.earningsCalendar.revenueActual', { value: revActFmt }))}`; } else if (revEstFmt) { revHtml = `${escapeHtml(t('components.earningsCalendar.revenueEstimate', { value: revEstFmt }))}`; } return `
${hourLabel ? `${escapeHtml(hourLabel)}` : ''}
${escapeHtml(e.company)}
${escapeHtml(e.symbol)}
${epsHtml ? `
${epsHtml}
` : ''} ${revHtml ? `
${revHtml}
` : ''}
`; } function renderGroup(date: string, entries: EarningsEntry[], isFirst: boolean): string { const borderStyle = isFirst ? '' : 'border-top:1px solid rgba(255,255,255,0.06);'; return `
${escapeHtml(dateLabel(date))}
${entries.map(renderEntry).join('')}
`; } export class EarningsCalendarPanel extends Panel { private _hasData = false; constructor() { super({ id: 'earnings-calendar', title: t('components.earningsCalendar.title'), showCount: false, infoTooltip: t('components.earningsCalendar.infoTooltip') }); } public async fetchData(): Promise { this.showLoading(); return this.refreshFromRpc(); } private async refreshFromRpc(): Promise { try { const client = await getMarketClient(); const today = new Date(); const future = new Date(); future.setDate(future.getDate() + 14); const fromDate = today.toISOString().slice(0, 10); const toDate = future.toISOString().slice(0, 10); const resp = await client.listEarningsCalendar({ fromDate, toDate }); if (resp.unavailable || !resp.earnings?.length) { if (!this._hasData) this.showError(t('components.earningsCalendar.errors.noData'), () => void this.fetchData()); return false; } this.render(resp.earnings as EarningsEntry[]); return true; } catch (e) { if (!this._hasData) this.showError(e instanceof Error ? e.message : t('components.earningsCalendar.errors.failedToLoad'), () => void this.fetchData()); return false; } } private render(earnings: EarningsEntry[]): void { this._hasData = true; const grouped = new Map(); for (const e of earnings) { const key = e.date || 'Unknown'; const arr = grouped.get(key); if (arr) arr.push(e); else grouped.set(key, [e]); } const sortedDates = [...grouped.keys()].sort(); const html = `
${sortedDates.map((d, i) => renderGroup(d, grouped.get(d)!, i === 0)).join('')}
`; this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); } }