File size: 8,024 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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);

  // 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 = `
      <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>`;
  }

  // Revenue section
  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'));
  }
}