| 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<MarketServiceClient> { |
| 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<typeof fetch>) => 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); |
|
|
| |
| 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 = ` |
| <span style="font-size:11px;font-weight:600;color:var(--text)">${escapeHtml(t('components.earningsCalendar.epsActual', { value: epsActFmt }))}</span> |
| <span style="font-size:9px;font-weight:700;padding:1px 4px;border-radius:3px;${badgeStyle}">${escapeHtml(badgeLabel)}${pct ? ` ${escapeHtml(pct)}` : ''}</span>`; |
| } else if (epsEstFmt) { |
| epsHtml = `<span style="font-size:11px;color:var(--text-dim)">${escapeHtml(t('components.earningsCalendar.epsEstimate', { value: epsEstFmt }))}</span>`; |
| } |
|
|
| |
| let revHtml = ''; |
| if (e.hasActuals && revActFmt) { |
| revHtml = `<span style="font-size:10px;color:var(--text-dim)">${escapeHtml(t('components.earningsCalendar.revenueActual', { value: revActFmt }))}</span>`; |
| } else if (revEstFmt) { |
| revHtml = `<span style="font-size:10px;color:rgba(255,255,255,0.25)">${escapeHtml(t('components.earningsCalendar.revenueEstimate', { value: revEstFmt }))}</span>`; |
| } |
|
|
| return ` |
| <div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.04)"> |
| <div style="display:flex;flex-direction:column;align-items:center;gap:3px;flex-shrink:0;padding-top:1px"> |
| ${hourLabel ? `<span style="font-size:9px;font-weight:700;padding:2px 5px;border-radius:3px;${hourStyle};letter-spacing:0.04em">${escapeHtml(hourLabel)}</span>` : ''} |
| </div> |
| <div style="flex:1;min-width:0"> |
| <div style="font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(e.company)}</div> |
| <div style="font-size:10px;color:var(--text-dim);letter-spacing:0.04em">${escapeHtml(e.symbol)}</div> |
| </div> |
| <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px;flex-shrink:0"> |
| ${epsHtml ? `<div style="display:flex;align-items:center;gap:5px">${epsHtml}</div>` : ''} |
| ${revHtml ? `<div>${revHtml}</div>` : ''} |
| </div> |
| </div>`; |
| } |
|
|
| function renderGroup(date: string, entries: EarningsEntry[], isFirst: boolean): string { |
| const borderStyle = isFirst ? '' : 'border-top:1px solid rgba(255,255,255,0.06);'; |
| return ` |
| <div style="${borderStyle}padding-top:${isFirst ? '0' : '10'}px;padding-bottom:2px"> |
| <div style="font-size:10px;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:0.06em;padding:0 0 5px">${escapeHtml(dateLabel(date))}</div> |
| ${entries.map(renderEntry).join('')} |
| </div>`; |
| } |
|
|
| 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<boolean> { |
| this.showLoading(); |
| return this.refreshFromRpc(); |
| } |
|
|
| private async refreshFromRpc(): Promise<boolean> { |
| 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<string, EarningsEntry[]>(); |
| 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 = ` |
| <div style="padding:0 14px 12px;max-height:480px;overflow-y:auto"> |
| ${sortedDates.map((d, i) => renderGroup(d, grouped.get(d)!, i === 0)).join('')} |
| </div>`; |
|
|
| this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); |
| } |
| } |
|
|