| import { Panel } from './Panel'; |
| import { t } from '@/services/i18n'; |
| import { hasPremiumAccess } from '@/services/panel-gating'; |
| import { FrameworkSelector } from './FrameworkSelector'; |
| import type { DailyMarketBrief } from '@/services/daily-market-brief'; |
| import { describeFreshness } from '@/services/persistent-cache'; |
| import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; |
| import { getChangeClass } from '@/utils'; |
| import { createWatchlistButton } from './watchlist-modal'; |
|
|
| type BriefSource = 'live' | 'cached'; |
|
|
| function formatGeneratedTime(isoTimestamp: string, timezone: string): string { |
| try { |
| return new Intl.DateTimeFormat('en-US', { |
| timeZone: timezone, |
| hour: 'numeric', |
| minute: '2-digit', |
| month: 'short', |
| day: 'numeric', |
| }).format(new Date(isoTimestamp)); |
| } catch { |
| return isoTimestamp; |
| } |
| } |
|
|
| function stanceLabel(stance: DailyMarketBrief['items'][number]['stance']): string { |
| if (stance === 'bullish') return 'Bullish'; |
| if (stance === 'defensive') return 'Defensive'; |
| return 'Neutral'; |
| } |
|
|
| function formatPrice(price: number | null): string { |
| if (typeof price !== 'number' || !Number.isFinite(price)) return 'N/A'; |
| return price.toLocaleString(undefined, { maximumFractionDigits: 2 }); |
| } |
|
|
| function formatChange(change: number | null): string { |
| if (typeof change !== 'number' || !Number.isFinite(change)) return 'Flat'; |
| const sign = change > 0 ? '+' : ''; |
| return `${sign}${change.toFixed(2)}%`; |
| } |
|
|
| export class DailyMarketBriefPanel extends Panel { |
| private fwSelector: FrameworkSelector; |
|
|
| constructor() { |
| super({ id: 'daily-market-brief', title: 'Daily Market Brief', infoTooltip: t('components.dailyMarketBrief.infoTooltip'), premium: 'locked' }); |
| this.fwSelector = new FrameworkSelector({ panelId: 'daily-market-brief', isPremium: hasPremiumAccess(), panel: this, note: 'Applies to client-generated analysis only' }); |
| this.header.appendChild(this.fwSelector.el); |
| this.header.appendChild(createWatchlistButton('Edit Watchlist')); |
| } |
|
|
| public override destroy(): void { |
| this.fwSelector.destroy(); |
| super.destroy(); |
| } |
|
|
| public renderBrief(brief: DailyMarketBrief, source: BriefSource = 'live'): void { |
| const freshness = describeFreshness(new Date(brief.generatedAt).getTime()); |
| this.setDataBadge(source, freshness); |
| this.resetRetryBackoff(); |
|
|
| const html = ` |
| <div class="daily-brief-shell" style="display:grid;gap:12px"> |
| <div class="daily-brief-card" style="display:grid;gap:6px;padding:12px;border:1px solid var(--border);border-radius:4px;background:rgba(255,255,255,0.03)"> |
| <div style="display:flex;align-items:center;justify-content:space-between;gap:12px"> |
| <div style="font-size:13px;font-weight:600">${escapeHtml(brief.title)}</div> |
| <div style="font-size:11px;color:var(--text-dim)">${escapeHtml(formatGeneratedTime(brief.generatedAt, brief.timezone))}</div> |
| </div> |
| <div style="font-size:12px;line-height:1.5;color:var(--text)">${escapeHtml(brief.summary)}</div> |
| </div> |
| |
| <div style="display:grid;gap:10px"> |
| <div style="padding:10px 12px;border:1px solid var(--border);border-radius:4px;background:rgba(255,255,255,0.02)"> |
| <div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px">Action Plan</div> |
| <div style="font-size:12px;line-height:1.5">${escapeHtml(brief.actionPlan)}</div> |
| </div> |
| <div style="padding:10px 12px;border:1px solid var(--border);border-radius:4px;background:rgba(255,255,255,0.02)"> |
| <div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px">Risk Watch</div> |
| <div style="font-size:12px;line-height:1.5">${escapeHtml(brief.riskWatch)}</div> |
| </div> |
| </div> |
| |
| <div style="display:grid;gap:8px"> |
| ${brief.items.map((item) => ` |
| <div style="display:grid;gap:6px;padding:10px 12px;border:1px solid var(--border);border-radius:4px;background:rgba(255,255,255,0.02)"> |
| <div style="display:flex;align-items:center;justify-content:space-between;gap:12px"> |
| <div> |
| <div style="font-size:12px;font-weight:600">${escapeHtml(item.name)}</div> |
| <div style="font-size:11px;color:var(--text-dim)">${escapeHtml(item.display)}</div> |
| </div> |
| <div style="text-align:right"> |
| <div style="font-size:12px;font-weight:600">${escapeHtml(formatPrice(item.price))}</div> |
| <div class="market-change ${getChangeClass(item.change ?? 0)}" style="font-size:11px">${escapeHtml(formatChange(item.change))}</div> |
| </div> |
| </div> |
| <div style="display:flex;align-items:center;justify-content:space-between;gap:12px"> |
| <div style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim)">${escapeHtml(stanceLabel(item.stance))}</div> |
| ${item.relatedHeadline ? `<div style="font-size:11px;color:var(--text-dim);text-align:right;max-width:55%">Linked headline</div>` : ''} |
| </div> |
| <div style="font-size:12px;line-height:1.45">${escapeHtml(item.note)}</div> |
| </div> |
| `).join('')} |
| </div> |
| |
| </div> |
| `; |
|
|
| this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); |
| } |
|
|
| public showUnavailable(message = 'The daily brief needs live market data before it can be generated.'): void { |
| this.showError(message); |
| } |
| } |
|
|