phase-7: code restructure
This commit is contained in:
committed by
saikiranvella
parent
c160e65bd6
commit
357b0c0f6e
@@ -57,7 +57,20 @@
|
||||
<div class="sb-list">
|
||||
{#each a.relatedTickers ?? [] as rt}
|
||||
<div class="sb-item">
|
||||
<span class="sb-name ticker">{rt.ticker}</span>
|
||||
<div class="sb-ticker-row">
|
||||
<span class="sb-name ticker">{rt.ticker}</span>
|
||||
<div class="sb-chips">
|
||||
{#if rt.bias}
|
||||
<span class="sb-chip sb-bias" data-bias={rt.bias}>{rt.bias}</span>
|
||||
{/if}
|
||||
{#if rt.horizon}
|
||||
<span class="sb-chip sb-horizon">{rt.horizon}</span>
|
||||
{/if}
|
||||
{#if rt.sensitivity}
|
||||
<span class="sb-chip sb-sensitivity" title="Sensitivity {rt.sensitivity}/5">S{rt.sensitivity}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="sb-reason">{rt.reason}</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -67,133 +80,3 @@
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #00000055;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: 380px;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--blue-surface);
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--blue-badge);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-type {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--blue-surface);
|
||||
color: var(--blue-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--text-muted); background: var(--bg-card); }
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.sidebar-error {
|
||||
color: var(--red);
|
||||
background: var(--red-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px var(--space-lg);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.sb-summary {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid var(--blue-surface);
|
||||
padding-left: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-sub {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dimmer);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sb-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sb-reason {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,14 +11,22 @@
|
||||
analyzeLoading = false,
|
||||
onAnalyze,
|
||||
}: {
|
||||
type: AssetType;
|
||||
rows: AssetResult[];
|
||||
type: AssetType;
|
||||
rows: AssetResult[];
|
||||
analyzeLoading?: boolean;
|
||||
onAnalyze: () => void;
|
||||
onAnalyze: () => void;
|
||||
} = $props();
|
||||
|
||||
// Mode state is self-contained — each table independently tracks inflated vs fundamental
|
||||
let mode = $state('inflated');
|
||||
|
||||
// Colour class for signed % values (52W Chg, From High, Upside, DCF Safety)
|
||||
function signClass(val: string | number | null | undefined): string {
|
||||
if (val == null) return '';
|
||||
const n = typeof val === 'number' ? val : parseFloat(String(val));
|
||||
if (isNaN(n)) return '';
|
||||
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="section">
|
||||
@@ -54,9 +62,27 @@
|
||||
<th>Verdict</th>
|
||||
<th>Score</th>
|
||||
{#if type === 'STOCK'}
|
||||
<th>Sector</th>
|
||||
<th>P/E</th><th>PEG</th><th>ROE%</th>
|
||||
<th>OpMgn%</th><th>FCF%</th><th>D/E</th>
|
||||
<!-- Classification -->
|
||||
<th title="Market cap tier">Cap</th>
|
||||
<th title="Growth / style classification">Style</th>
|
||||
<!-- Valuation -->
|
||||
<th>P/E</th>
|
||||
<th>PEG</th>
|
||||
<!-- Quality -->
|
||||
<th title="Gross Margin %">GrossM%</th>
|
||||
<th>ROE%</th>
|
||||
<th>OpMgn%</th>
|
||||
<th>FCF%</th>
|
||||
<!-- Risk -->
|
||||
<th>D/E</th>
|
||||
<!-- 52-week movement -->
|
||||
<th title="Total price return over last 52 weeks">52W Chg</th>
|
||||
<th title="% below 52-week high">From High</th>
|
||||
<!-- Expert signals -->
|
||||
<th title="Wall Street analyst consensus">Analyst</th>
|
||||
<th title="% upside to analyst target price">Upside</th>
|
||||
<th title="DCF margin of safety — positive means undervalued">DCF Safety</th>
|
||||
<!-- Risk flags -->
|
||||
<th>Flags</th>
|
||||
{:else if type === 'ETF'}
|
||||
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
|
||||
@@ -68,20 +94,34 @@
|
||||
<tbody>
|
||||
{#each sorted(rows) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
{@const v = r[mode]}
|
||||
{@const v = r[mode as 'inflated' | 'fundamental']}
|
||||
<tr class="data-row" data-signal={sigOrd(r.signal)}>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td><VerdictPill label={v.label} /></td>
|
||||
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
|
||||
{#if type === 'STOCK'}
|
||||
<td><span class="tag sm">{m.Sector ?? '—'}</span></td>
|
||||
<!-- Classification -->
|
||||
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
|
||||
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
|
||||
<!-- Valuation -->
|
||||
<td class="num">{m['P/E'] ?? '—'}</td>
|
||||
<td class="num">{m['PEG'] ?? '—'}</td>
|
||||
<!-- Quality -->
|
||||
<td class="num">{m['GrossM%'] ?? '—'}</td>
|
||||
<td class="num">{m['ROE%'] ?? '—'}</td>
|
||||
<td class="num">{m['OpMgn%'] ?? '—'}</td>
|
||||
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
|
||||
<!-- Risk -->
|
||||
<td class="num">{m['D/E'] ?? '—'}</td>
|
||||
<!-- 52-week movement — green if up, red if down -->
|
||||
<td class="num {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
|
||||
<td class="num {signClass(m['From High'])}">{m['From High'] ?? '—'}</td>
|
||||
<!-- Expert signals -->
|
||||
<td class="analyst-cell">{m['Analyst'] ?? '—'}</td>
|
||||
<td class="num {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</td>
|
||||
<td class="num {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</td>
|
||||
<!-- Risk flags -->
|
||||
<td class="flags">
|
||||
{#each v.audit?.riskFlags ?? [] as flag}
|
||||
<span class="flag">⚠ {flag}</span>
|
||||
@@ -105,15 +145,32 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Score cell — truncates long gate summaries, full text via title tooltip */
|
||||
/* Score cell — truncates long gate summaries, tooltip shows full text */
|
||||
.score-cell {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-sm);
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flags { display: flex; flex-direction: column; gap: 2px; }
|
||||
.flag { color: var(--orange); font-size: var(--fs-sm); }
|
||||
/* Classification tags */
|
||||
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
|
||||
.style-tag { color: var(--text-muted); }
|
||||
|
||||
/* Signed % colouring */
|
||||
.pos { color: var(--green); }
|
||||
.neg { color: var(--red); }
|
||||
|
||||
/* Analyst label — not a number */
|
||||
.analyst-cell {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Risk flags column */
|
||||
.flags { display: flex; flex-direction: column; gap: 2px; min-width: 160px; }
|
||||
.flag { color: var(--orange); font-size: var(--fs-sm); white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' },
|
||||
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) },
|
||||
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() },
|
||||
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE?.toFixed(1)) },
|
||||
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE?.toFixed(1)) },
|
||||
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) },
|
||||
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) },
|
||||
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' },
|
||||
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' },
|
||||
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime },
|
||||
@@ -36,7 +36,6 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ctx-chip {
|
||||
|
||||
+6
-127
@@ -1,127 +1,6 @@
|
||||
import type {
|
||||
ScreenerResult,
|
||||
MarketContext,
|
||||
MarketCall,
|
||||
CalendarEvent,
|
||||
CatalystStory,
|
||||
LLMAnalysis,
|
||||
PortfolioHolding,
|
||||
PortfolioAdvice,
|
||||
} from '$lib/types.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
// ── Screener ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||
const res = await fetch(`${BASE}/screen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> {
|
||||
const res = await fetch(`${BASE}/screen/catalysts`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function analyzeTickers(tickers: string[]): Promise<{ analysis: LLMAnalysis | null }> {
|
||||
const res = await fetch(`${BASE}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Finance / Portfolio ───────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchPortfolio(): Promise<{
|
||||
advice: PortfolioAdvice[];
|
||||
holdings: PortfolioHolding[];
|
||||
marketContext: MarketContext | null;
|
||||
netWorth: number | null;
|
||||
error?: string;
|
||||
}> {
|
||||
const res = await fetch(`${BASE}/finance/portfolio`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addHolding(
|
||||
holding: Omit<PortfolioHolding, never>,
|
||||
): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(holding),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMarketContext(): Promise<MarketContext> {
|
||||
const res = await fetch(`${BASE}/finance/market-context`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Market Calls ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchCalls(): Promise<{ calls: MarketCall[] }> {
|
||||
const res = await fetch(`${BASE}/calls`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCall(id: string): Promise<MarketCall & { current: ScreenerResult }> {
|
||||
const res = await fetch(`${BASE}/calls/${id}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createCall(payload: {
|
||||
title: string;
|
||||
quarter: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
date?: string;
|
||||
}): Promise<MarketCall> {
|
||||
const res = await fetch(`${BASE}/calls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteCall(id: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCallsCalendar(
|
||||
tickers: string[] | null = null,
|
||||
): Promise<{ events: CalendarEvent[] }> {
|
||||
const url = tickers?.length
|
||||
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
|
||||
: `${BASE}/calls/calendar`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
// ── Backward-compat shim ──────────────────────────────────────────────────
|
||||
// All API functions now live in $lib/api/*.ts domain modules.
|
||||
// This file re-exports everything so existing import sites are unaffected.
|
||||
// New code should import directly from the domain module:
|
||||
// import { screenTickers } from '$lib/api/screener.js'
|
||||
export * from './api/index.js';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
export async function fetchCalls(): Promise<{ calls: MarketCall[] }> {
|
||||
const res = await fetch(`${BASE}/calls`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCall(id: string): Promise<MarketCall & { current: ScreenerResult }> {
|
||||
const res = await fetch(`${BASE}/calls/${id}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createCall(payload: {
|
||||
title: string;
|
||||
quarter: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
date?: string;
|
||||
}): Promise<MarketCall> {
|
||||
const res = await fetch(`${BASE}/calls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteCall(id: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCallsCalendar(
|
||||
tickers: string[] | null = null,
|
||||
): Promise<{ events: CalendarEvent[] }> {
|
||||
const url = tickers?.length
|
||||
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
|
||||
: `${BASE}/calls/calendar`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
export async function fetchPortfolio(): Promise<{
|
||||
advice: PortfolioAdvice[];
|
||||
holdings: PortfolioHolding[];
|
||||
marketContext: MarketContext | null;
|
||||
netWorth: number | null;
|
||||
error?: string;
|
||||
}> {
|
||||
const res = await fetch(`${BASE}/finance/portfolio`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addHolding(
|
||||
holding: PortfolioHolding,
|
||||
): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(holding),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMarketContext(): Promise<MarketContext> {
|
||||
const res = await fetch(`${BASE}/finance/market-context`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ── API module barrel ─────────────────────────────────────────────────────
|
||||
// Drop-in replacement for the old $lib/api.ts flat file.
|
||||
// Existing imports from '$lib/api.js' continue to work via api.ts re-export.
|
||||
|
||||
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
|
||||
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
||||
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ScreenerResult } from '$lib/types.js';
|
||||
import type { LLMAnalysis, CatalystStory } from '$lib/types.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
export async function screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||
const res = await fetch(`${BASE}/screen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> {
|
||||
const res = await fetch(`${BASE}/screen/catalysts`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function analyzeTickers(
|
||||
tickers: string[],
|
||||
): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> {
|
||||
const res = await fetch(`${BASE}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarEvent } from '$lib/types.js';
|
||||
|
||||
let { events }: { events: CalendarEvent[] } = $props();
|
||||
|
||||
type EventType = 'earnings' | 'exdividend' | 'dividend';
|
||||
const eventIcon = (t: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[t] ?? '📅';
|
||||
const eventColor = (t: EventType): string =>
|
||||
({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[t] ?? '#94a3b8';
|
||||
|
||||
const fmtMoney = (n: number | null | undefined): string | null => n == null ? null :
|
||||
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
|
||||
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
|
||||
|
||||
const upcoming = $derived(events.filter(e => !e.isPast).slice(0, 20));
|
||||
const past = $derived(events.filter(e => e.isPast).slice(0, 10));
|
||||
</script>
|
||||
|
||||
{#if events.length > 0}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>📅 Upcoming Events</h2>
|
||||
<span class="count">{upcoming.length} upcoming</span>
|
||||
{#if past.length > 0}
|
||||
<span class="count" style="margin-left:4px">{past.length} recent</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cal-grid">
|
||||
{#each upcoming as ev}
|
||||
<div class="cal-event">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type" style="color:{eventColor(ev.type)}">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
|
||||
</span>
|
||||
{#if ev.epsEstimate != null}
|
||||
<span class="cal-est">EPS est. ${ev.epsEstimate.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if past.length > 0}
|
||||
<div class="cal-divider">— Past —</div>
|
||||
{#each past as ev}
|
||||
<div class="cal-event past">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type past-type">{eventIcon(ev.type)} {ev.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
interface TickerSnapshot {
|
||||
price: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
interface MarketCall {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, TickerSnapshot>;
|
||||
}
|
||||
|
||||
let {
|
||||
call,
|
||||
onDelete,
|
||||
}: {
|
||||
call: MarketCall;
|
||||
onDelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const signalColor = (s: string | null | undefined): string => {
|
||||
if (s?.includes('Strong')) return '#4ade80';
|
||||
if (s?.includes('Momentum')) return '#60a5fa';
|
||||
if (s?.includes('Neutral')) return '#94a3b8';
|
||||
if (s?.includes('Speculation')) return '#fb923c';
|
||||
return '#f87171';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="section call-card">
|
||||
<div class="section-header">
|
||||
<div class="call-card-meta">
|
||||
<a href="/calls/{call.id}" class="call-card-title">{call.title}</a>
|
||||
<div class="call-card-badges">
|
||||
<span class="tag">{call.quarter}</span>
|
||||
<span class="call-date-badge">{call.date}</span>
|
||||
<span class="count">{call.tickers.length} tickers</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-call-delete" onclick={() => onDelete(call.id)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="call-card-body">
|
||||
<p class="call-thesis">{call.thesis}</p>
|
||||
|
||||
{#if Object.keys(call.snapshot ?? {}).length}
|
||||
<div class="snapshot-grid">
|
||||
{#each call.tickers as ticker}
|
||||
{@const snap = call.snapshot[ticker]}
|
||||
{#if snap}
|
||||
<a href="/calls/{call.id}" class="snap-card">
|
||||
<div class="snap-ticker">{ticker}</div>
|
||||
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
|
||||
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
|
||||
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/calls/{call.id}" class="call-view-link">View performance →</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
|
||||
interface FormData {
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string;
|
||||
}
|
||||
|
||||
let {
|
||||
saving = false,
|
||||
error = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (data: FormData) => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
|
||||
function currentQuarter(): string {
|
||||
const d = new Date();
|
||||
return `Q${Math.ceil((d.getMonth() + 1) / 3)} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
let form = $state<FormData>({
|
||||
title: '',
|
||||
quarter: currentQuarter(),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
thesis: '',
|
||||
tickers: '',
|
||||
});
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({ ...form });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="section form-section">
|
||||
<div class="section-header"><h2>New Market Call</h2></div>
|
||||
<form class="call-form" onsubmit={handleSubmit}>
|
||||
<div class="call-form-row">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input bind:value={form.title} placeholder="Q3 2025 — Rate pivot & tech rotation" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Quarter</span>
|
||||
<input bind:value={form.quarter} placeholder="Q3 2025" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Date</span>
|
||||
<input type="date" bind:value={form.date} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Thesis</span>
|
||||
<textarea
|
||||
bind:value={form.thesis}
|
||||
rows="4"
|
||||
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tickers to track</span>
|
||||
<input bind:value={form.tickers} placeholder="AAPL, MSFT, TLT, GLD …" required />
|
||||
<span class="call-hint">Comma or space separated. Current prices will be snapshot automatically.</span>
|
||||
</label>
|
||||
{#if error}
|
||||
<div class="form-error-block">⚠ {error}</div>
|
||||
{/if}
|
||||
<div class="call-form-actions">
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{#if saving}<Spinner size="sm" /><span>Snapshotting prices…</span>
|
||||
{:else}Save Call{/if}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost" onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { fmt, fmtShort } from '$lib/utils.js';
|
||||
import type { PersonalFinance } from '$lib/types.js';
|
||||
|
||||
let { pf }: { pf: PersonalFinance } = $props();
|
||||
</script>
|
||||
|
||||
<div class="pnl-grid">
|
||||
<div class="pnl-card"><div class="pnl-label">Net Worth</div>
|
||||
<div class="pnl-value {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Total Assets</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalAssets)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Liabilities</div>
|
||||
<div class="pnl-value red">{fmtShort(pf.totalLiabilities)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Cash ({pf.cashPct}%)</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalCash)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Investments ({pf.investPct}%)</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalInvestments)}</div></div>
|
||||
{#if pf.savingsRate != null}
|
||||
<div class="pnl-card"><div class="pnl-label">Savings Rate</div>
|
||||
<div class="pnl-value {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div></div>
|
||||
{/if}
|
||||
<div class="pnl-card"><div class="pnl-label">Monthly Income</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalIncome)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Monthly Spend</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalSpend)}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-two-col">
|
||||
<section class="accounts-section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.accounts as a}
|
||||
<tr>
|
||||
<td class="ticker">{a.name}</td>
|
||||
<td><span class="tag">{a.type}</span></td>
|
||||
<td class="gray">{a.org}</td>
|
||||
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="accounts-section">
|
||||
<h2>Spending — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.categoryBreakdown.slice(0, 10) as c}
|
||||
<tr>
|
||||
<td>{c.category}</td>
|
||||
<td class="num right">{fmt(c.amount)}</td>
|
||||
<td class="num right gray">{c.pct}%</td>
|
||||
<td style="width:100px">
|
||||
<div class="spend-bar-bg"><div class="spend-bar-fill" style="width:{Math.min(c.pct,100)}%"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { HoldingFormData } from '$lib/types.js';
|
||||
|
||||
let {
|
||||
saving = false,
|
||||
error = null as string | null,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (data: HoldingFormData) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
|
||||
|
||||
function handleSubmit() {
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const shares = parseFloat(form.shares);
|
||||
const costBasis = parseFloat(form.costBasis) || 0;
|
||||
if (!ticker || !shares || shares <= 0) return;
|
||||
onSubmit({
|
||||
ticker,
|
||||
shares,
|
||||
costBasis,
|
||||
type: form.type as HoldingFormData['type'],
|
||||
source: form.source,
|
||||
});
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-form">
|
||||
<div class="add-form-title">Add Holding</div>
|
||||
<div class="add-form-row">
|
||||
<div class="field">
|
||||
<label for="form-ticker">Ticker</label>
|
||||
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-shares">Shares</label>
|
||||
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-cost">Cost Basis / share</label>
|
||||
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-type">Type</label>
|
||||
<select id="form-type" bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
<option value="crypto">Crypto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-source">Source</label>
|
||||
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-form-save" onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="btn-form-cancel" onclick={onClose}>✕</button>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="form-error">⚠ {error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
|
||||
import type { AdviceRow } from '$lib/types.js';
|
||||
|
||||
export interface UpdateData {
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
let {
|
||||
rows,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
rows: AdviceRow[];
|
||||
onUpdate: (ticker: string, data: UpdateData) => void;
|
||||
onDelete: (ticker: string) => void;
|
||||
} = $props();
|
||||
|
||||
// ── Sort ──────────────────────────────────────────────────────────
|
||||
let sortCol = $state('ticker');
|
||||
let sortDir = $state(1);
|
||||
|
||||
function toggleSort(col: string) {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
}
|
||||
|
||||
const sortIcon = (col: string) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
|
||||
|
||||
const sorted = $derived.by(() => [...rows].sort((a, b) => {
|
||||
let av: string | number, bv: string | number;
|
||||
switch (sortCol) {
|
||||
case 'ticker': av = a.ticker; bv = b.ticker; break;
|
||||
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
|
||||
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
|
||||
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
|
||||
case 'current': av = parseFloat(a.currentPrice ?? '0') || 0; bv = parseFloat(b.currentPrice ?? '0') || 0; break;
|
||||
case 'value': av = parseFloat(a.marketValue ?? '0') || 0; bv = parseFloat(b.marketValue ?? '0') || 0; break;
|
||||
case 'gl': av = parseFloat(a.gainLossPct ?? '0') || 0; bv = parseFloat(b.gainLossPct ?? '0') || 0; break;
|
||||
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
|
||||
default: return 0;
|
||||
}
|
||||
return av < bv ? -sortDir : av > bv ? sortDir : 0;
|
||||
}));
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────
|
||||
const totalValue = $derived(rows.reduce((s, a) => s + (parseFloat(a.marketValue ?? '0') || 0), 0));
|
||||
const totalCost = $derived(rows.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0));
|
||||
const totalGL = $derived(totalValue - totalCost);
|
||||
|
||||
// ── Inline edit ───────────────────────────────────────────────────
|
||||
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
|
||||
let editing: InlineEdit | null = $state(null);
|
||||
let saving = $state(false);
|
||||
|
||||
function startEdit(a: AdviceRow) {
|
||||
editing = {
|
||||
ticker: a.ticker,
|
||||
shares: String(a.shares),
|
||||
costBasis: String(a.costBasis ?? 0),
|
||||
type: a.type ?? 'stock',
|
||||
source: a.source ?? 'Robinhood',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
saving = true;
|
||||
onUpdate(editing.ticker, {
|
||||
shares: parseFloat(editing.shares),
|
||||
costBasis: parseFloat(editing.costBasis) || 0,
|
||||
type: editing.type,
|
||||
source: editing.source,
|
||||
});
|
||||
editing = null;
|
||||
saving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- P&L Summary -->
|
||||
<div class="pnl-grid">
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total Value</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Current market value of all holdings. Shares × live price from Yahoo Finance.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value">{fmtShort(totalValue)}</div>
|
||||
</div>
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total Cost</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total amount invested — sum of cost basis × shares across all positions.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value">{fmtShort(totalCost)}</div>
|
||||
</div>
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total G/L</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holdings table -->
|
||||
<section class="advice-section">
|
||||
<h2>Holdings — Hold / Sell / Add Advice</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
|
||||
<th>Advice</th><th>Reason</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as a}
|
||||
{@const isEditing = editing?.ticker === a.ticker}
|
||||
<tr class:editing={isEditing}>
|
||||
<td class="ticker">{a.ticker}</td>
|
||||
<td>
|
||||
{#if isEditing && editing}
|
||||
<select class="inline-select" bind:value={editing.type}>
|
||||
<option value="stock">stock</option>
|
||||
<option value="etf">etf</option>
|
||||
<option value="bond">bond</option>
|
||||
<option value="crypto">crypto</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="tag">{a.type}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing && editing}
|
||||
<input class="inline-input" bind:value={editing.shares} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{a.shares}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing && editing}
|
||||
<input class="inline-input" bind:value={editing.costBasis} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{fmt(a.costBasis)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{fmt(a.currentPrice != null ? parseFloat(a.currentPrice) : null)}</td>
|
||||
<td class="num">{fmt(a.marketValue != null ? parseFloat(a.marketValue) : null)}</td>
|
||||
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray">—</span>{/if}</td>
|
||||
<td class={advClass(a.advice)}>{a.advice}</td>
|
||||
<td class="reason">{a.reason}</td>
|
||||
<td class="advice-row-actions">
|
||||
{#if isEditing}
|
||||
<button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button>
|
||||
<button class="btn-cancel-inline" onclick={() => editing = null}>✕</button>
|
||||
{:else}
|
||||
<button class="btn-row-edit" onclick={() => startEdit(a)} title="Edit">✎</button>
|
||||
<button class="btn-row-delete" onclick={() => onDelete(a.ticker)} title="Remove">✕</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { addHolding, removeHolding } from '$lib/api.js';
|
||||
import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js';
|
||||
|
||||
interface PortfolioData {
|
||||
advice: AdviceRow[];
|
||||
marketContext: MarketContext | null;
|
||||
personalFinance: PersonalFinance | null;
|
||||
}
|
||||
|
||||
class PortfolioStore {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
data = $state<PortfolioData | null>(null);
|
||||
loading = $state(true);
|
||||
refreshing = $state(false);
|
||||
loadError = $state<string | null>(null);
|
||||
formOpen = $state(false);
|
||||
saving = $state(false);
|
||||
formError = $state<string | null>(null);
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────
|
||||
fetch(showFullSpinner = false): void {
|
||||
if (showFullSpinner) this.loading = true;
|
||||
else this.refreshing = true;
|
||||
this.loadError = null;
|
||||
|
||||
window
|
||||
.fetch('/api/finance/portfolio')
|
||||
.then((res) =>
|
||||
res.ok
|
||||
? res.json()
|
||||
: res.text().then((t) => {
|
||||
throw new Error(t);
|
||||
}),
|
||||
)
|
||||
.then((json: PortfolioData) => {
|
||||
this.data = json;
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
this.loadError = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
this.refreshing = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Add holding ────────────────────────────────────────────────────
|
||||
async add(formData: HoldingFormData): Promise<void> {
|
||||
this.formError = null;
|
||||
this.saving = true;
|
||||
try {
|
||||
await addHolding(formData);
|
||||
// Optimistic: insert placeholder row immediately
|
||||
const exists = this.data?.advice?.find((a) => a.ticker === formData.ticker);
|
||||
if (this.data?.advice && !exists) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
advice: [
|
||||
...this.data.advice,
|
||||
{
|
||||
ticker: formData.ticker,
|
||||
shares: formData.shares,
|
||||
costBasis: formData.costBasis,
|
||||
type: formData.type,
|
||||
source: formData.source,
|
||||
currentPrice: null,
|
||||
marketValue: null,
|
||||
gainLossPct: null,
|
||||
signal: null,
|
||||
advice: '⏳ Fetching…',
|
||||
reason: 'Screener data loading in background.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
this.formOpen = false;
|
||||
this.fetch(false);
|
||||
} catch (e) {
|
||||
this.formError = (e as Error).message;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update holding ─────────────────────────────────────────────────
|
||||
async update(
|
||||
ticker: string,
|
||||
updated: { shares: number; costBasis: number; type: string; source: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await addHolding({ ticker, ...updated, type: updated.type as HoldingFormData['type'] });
|
||||
if (this.data?.advice) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
advice: this.data.advice.map((a) =>
|
||||
a.ticker === ticker
|
||||
? {
|
||||
...a,
|
||||
...updated,
|
||||
marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)),
|
||||
gainLossPct: a.currentPrice
|
||||
? (
|
||||
((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: null,
|
||||
}
|
||||
: a,
|
||||
),
|
||||
};
|
||||
}
|
||||
this.fetch(false);
|
||||
} catch (e) {
|
||||
this.loadError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete holding ─────────────────────────────────────────────────
|
||||
async remove(ticker: string): Promise<void> {
|
||||
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
|
||||
if (this.data?.advice) {
|
||||
this.data = { ...this.data, advice: this.data.advice.filter((a) => a.ticker !== ticker) };
|
||||
}
|
||||
try {
|
||||
await removeHolding(ticker);
|
||||
this.fetch(false);
|
||||
} catch (e) {
|
||||
this.loadError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form helpers ───────────────────────────────────────────────────
|
||||
openForm(): void {
|
||||
this.formOpen = true;
|
||||
this.formError = null;
|
||||
}
|
||||
closeForm(): void {
|
||||
this.formOpen = false;
|
||||
this.formError = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const portfolioStore = new PortfolioStore();
|
||||
export type { PortfolioData };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fetchCatalysts, screenTickers, analyzeTickers } from '$lib/api.js';
|
||||
import { sorted } from '$lib/utils.js';
|
||||
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
||||
|
||||
class ScreenerStore {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
input = $state('');
|
||||
results = $state<ScreenerResult | null>(null);
|
||||
screenedAt = $state('');
|
||||
loading = $state(false);
|
||||
loadingCats = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
sidebar = $state<SidebarState>({
|
||||
open: false,
|
||||
loading: false,
|
||||
analysis: null,
|
||||
type: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────
|
||||
ctx = $derived(this.results?.marketContext ?? null);
|
||||
|
||||
allAssets = $derived(
|
||||
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
||||
);
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
async screen(): Promise<void> {
|
||||
this.error = null;
|
||||
this.loading = true;
|
||||
try {
|
||||
const tickers = this.input
|
||||
.split(/[\s,]+/)
|
||||
.map((t) => t.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
this.results = await screenTickers(tickers);
|
||||
this.screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
this.error = (e as Error).message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadCatalysts(): Promise<void> {
|
||||
this.loadingCats = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const cat = await fetchCatalysts();
|
||||
this.input = cat.tickers.join(', ');
|
||||
this.results = await screenTickers(cat.tickers);
|
||||
this.screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
this.error = (e as Error).message;
|
||||
} finally {
|
||||
this.loadingCats = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runTabAnalysis(type: AssetType): Promise<void> {
|
||||
const tickers = (this.results?.[type] ?? []).map((r) => r.asset.ticker);
|
||||
if (!tickers.length) return;
|
||||
this.sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
||||
try {
|
||||
const res = await analyzeTickers(tickers);
|
||||
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
|
||||
this.sidebar = {
|
||||
open: true,
|
||||
loading: false,
|
||||
analysis: res.analysis,
|
||||
type,
|
||||
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.'),
|
||||
};
|
||||
} catch (e) {
|
||||
this.sidebar = {
|
||||
open: true,
|
||||
loading: false,
|
||||
analysis: null,
|
||||
type,
|
||||
error: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
closeSidebar(): void {
|
||||
this.sidebar = { ...this.sidebar, open: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const screenerStore = new ScreenerStore();
|
||||
+126
-95
@@ -1,88 +1,100 @@
|
||||
// ── Shared UI types ───────────────────────────────────────────────────────
|
||||
// Mirror of the server's domain types, used across Svelte components.
|
||||
// ── UI type layer ─────────────────────────────────────────────────────────
|
||||
// Shared domain types are imported from the server's canonical model files
|
||||
// via the $types alias (→ server/types/). Only UI-specific types live here.
|
||||
//
|
||||
// All consumers should import from '$lib/types.js' as before — nothing changes
|
||||
// at the call site.
|
||||
|
||||
export type Signal =
|
||||
| '✅ Strong Buy'
|
||||
| '⚡ Momentum'
|
||||
| '⚠️ Speculation'
|
||||
| '🔄 Neutral'
|
||||
| '❌ Avoid';
|
||||
// ── Re-export shared domain types ────────────────────────────────────────
|
||||
export type {
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreMode,
|
||||
ScoreResult,
|
||||
AssetResult,
|
||||
ScreenerResult,
|
||||
} from '$types/asset.model.js';
|
||||
|
||||
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
|
||||
export type ScoreMode = 'inflated' | 'fundamental';
|
||||
export type {
|
||||
RateRegime,
|
||||
VolatilityRegime,
|
||||
Benchmarks,
|
||||
MarketContext,
|
||||
} from '$types/market.model.js';
|
||||
|
||||
export interface Benchmarks {
|
||||
marketPE: number | null;
|
||||
techPE: number | null;
|
||||
reitYield: number | null;
|
||||
igSpread: number | null;
|
||||
}
|
||||
export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js';
|
||||
|
||||
export interface MarketContext {
|
||||
sp500Price: number | null;
|
||||
riskFreeRate: number | null;
|
||||
vixLevel: number | null;
|
||||
rateRegime: 'HIGH' | 'NORMAL' | 'LOW';
|
||||
volatilityRegime: 'HIGH' | 'NORMAL' | 'LOW';
|
||||
benchmarks: Benchmarks;
|
||||
}
|
||||
export type { TickerSnapshot, MarketCall } from '$types/calls.model.js';
|
||||
|
||||
export interface ScoreResult {
|
||||
label: string;
|
||||
score: number;
|
||||
scoreSummary: string;
|
||||
audit: {
|
||||
riskFlags?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js';
|
||||
|
||||
// ── UI-only types (not on the server) ────────────────────────────────────
|
||||
|
||||
import type { AssetType } from '$types/asset.model.js';
|
||||
import type { LLMAnalysis } from '$types/finance.model.js';
|
||||
|
||||
/** Detailed display metrics rendered per asset row in the screener table. */
|
||||
export interface AssetDisplayMetrics {
|
||||
// ── Common ──────────────────────────────────────────────────────────
|
||||
Price?: string;
|
||||
|
||||
// ── Stock: classification ────────────────────────────────────────────
|
||||
Sector?: string;
|
||||
'Cap Tier'?: string; // Mega Cap / Large Cap / Mid Cap / Small Cap / Micro Cap
|
||||
Style?: string; // High Growth / Growth / Stable / Value / Turnaround / Declining
|
||||
|
||||
// ── Stock: valuation ─────────────────────────────────────────────────
|
||||
'P/E'?: string;
|
||||
PEG?: string;
|
||||
'P/B'?: string;
|
||||
|
||||
// ── Stock: quality ───────────────────────────────────────────────────
|
||||
'GrossM%'?: string; // gross margin — key for tech/software moat
|
||||
'ROE%'?: string;
|
||||
'OpMgn%'?: string;
|
||||
'NetMgn%'?: string;
|
||||
'FCF Yld%'?: string;
|
||||
'Div%'?: string;
|
||||
|
||||
// ── Stock: risk ───────────────────────────────────────────────────────
|
||||
'D/E'?: string;
|
||||
Quick?: string;
|
||||
Beta?: string;
|
||||
|
||||
// ── Stock: 52-week movement ───────────────────────────────────────────
|
||||
'52W Pos'?: string; // % position within the 52-week range
|
||||
'52W Chg'?: string; // total price return over last 52 weeks (signed %)
|
||||
'From High'?: string; // % below 52-week high (negative = drawdown)
|
||||
'From Low'?: string; // % above 52-week low (positive = recovery)
|
||||
|
||||
// ── Stock: analyst consensus ──────────────────────────────────────────
|
||||
Analyst?: string; // Strong Buy / Buy / Hold / Sell / Strong Sell
|
||||
'# Analysts'?: string;
|
||||
Target?: string; // analyst consensus price target
|
||||
Upside?: string; // % upside to analyst target (signed %)
|
||||
|
||||
// ── Stock: DCF intrinsic value ────────────────────────────────────────
|
||||
'DCF Value'?: string; // intrinsic value per share
|
||||
'DCF Safety'?: string; // margin of safety % (positive = undervalued)
|
||||
|
||||
// ── Stock: REIT-specific ──────────────────────────────────────────────
|
||||
'P/FFO'?: string;
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────
|
||||
'Exp Ratio%'?: string;
|
||||
'Yield%'?: string;
|
||||
AUM?: string;
|
||||
'5Y Return%'?: string;
|
||||
|
||||
// ── Bond ──────────────────────────────────────────────────────────────
|
||||
'YTM%'?: string;
|
||||
Duration?: string;
|
||||
Rating?: string;
|
||||
|
||||
[key: string]: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface AssetResult {
|
||||
asset: {
|
||||
ticker: string;
|
||||
currentPrice: number;
|
||||
type: AssetType;
|
||||
displayMetrics: AssetDisplayMetrics;
|
||||
};
|
||||
signal: Signal;
|
||||
inflated: ScoreResult;
|
||||
fundamental: ScoreResult;
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
STOCK: AssetResult[];
|
||||
ETF: AssetResult[];
|
||||
BOND: AssetResult[];
|
||||
ERROR: Array<{ ticker: string; message: string }>;
|
||||
marketContext: MarketContext;
|
||||
}
|
||||
|
||||
export interface LLMAnalysis {
|
||||
summary: string;
|
||||
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
|
||||
affectedIndustries: Array<{ name: string; reason: string }>;
|
||||
relatedTickers: Array<{ ticker: string; reason: string }>;
|
||||
}
|
||||
|
||||
/** State object for the LLM analysis slide-over sidebar. */
|
||||
export interface SidebarState {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
@@ -91,49 +103,68 @@ export interface SidebarState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioHolding {
|
||||
/** Transient state for inline row editing in the portfolio table. */
|
||||
export interface InlineEdit {
|
||||
ticker: string;
|
||||
shares: string;
|
||||
costBasis: string;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ── Portfolio component types ─────────────────────────────────────────────
|
||||
|
||||
import type { Signal } from '$types/asset.model.js';
|
||||
|
||||
/** A single row in the portfolio advice table. */
|
||||
export interface AdviceRow {
|
||||
ticker: string;
|
||||
type: string;
|
||||
source: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
currentPrice: string | null;
|
||||
marketValue: string | null;
|
||||
gainLossPct: string | null;
|
||||
signal: Signal | null;
|
||||
advice: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Form data for adding or updating a holding. */
|
||||
export interface HoldingFormData {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
source: string;
|
||||
type: 'stock' | 'etf' | 'bond' | 'crypto';
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface TickerSnapshot {
|
||||
price: number | null;
|
||||
signal: Signal | null;
|
||||
interface SimpleFINAccount {
|
||||
name: string;
|
||||
type: string;
|
||||
org: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface MarketCall {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, TickerSnapshot>;
|
||||
interface CategoryBreakdown {
|
||||
category: string;
|
||||
amount: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
ticker: string;
|
||||
type: 'earnings' | 'dividend';
|
||||
date: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CatalystStory {
|
||||
title: string;
|
||||
link: string;
|
||||
publisher: string;
|
||||
publishedAt: string;
|
||||
relatedTickers: string[];
|
||||
}
|
||||
|
||||
export interface PortfolioAdvice {
|
||||
ticker: string;
|
||||
action: 'hold' | 'sell' | 'add' | 'watch';
|
||||
reason: string;
|
||||
signal: Signal | null;
|
||||
currentPrice: number | null;
|
||||
gainLossPct: number | null;
|
||||
/** Personal finance summary from SimpleFIN. */
|
||||
export interface PersonalFinance {
|
||||
netWorth: number;
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
totalCash: number;
|
||||
totalInvestments: number;
|
||||
totalIncome: number;
|
||||
totalSpend: number;
|
||||
cashPct: number;
|
||||
investPct: number;
|
||||
savingsRate: string | null;
|
||||
accounts: SimpleFINAccount[];
|
||||
categoryBreakdown: CategoryBreakdown[];
|
||||
}
|
||||
|
||||
+58
-105
@@ -1,80 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { screenTickers, analyzeTickers } from '$lib/api.js';
|
||||
import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
||||
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
||||
|
||||
interface PageData { results: ScreenerResult; catalystInput: string }
|
||||
let { data }: { data: PageData } = $props();
|
||||
const s = screenerStore;
|
||||
|
||||
let input: string = $state(data.catalystInput);
|
||||
let results: ScreenerResult = $state(data.results);
|
||||
let screenedAt: string = $state(new Date().toLocaleTimeString());
|
||||
let loading: boolean = $state(false);
|
||||
let loadingCats: boolean = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let searchOpen: boolean = $state(false);
|
||||
let { data: _data } = $props();
|
||||
|
||||
// ── LLM Analysis sidebar ────────────────────────────────────────────────
|
||||
let sidebar: SidebarState = $state({ open: false, loading: false, analysis: null, type: null, error: null });
|
||||
// Pure UI state — not shared, kept local
|
||||
let searchOpen = $state(false);
|
||||
|
||||
async function runTabAnalysis(type: AssetType): Promise<void> {
|
||||
const tickers = (results?.[type] ?? []).map((r) => r.asset.ticker);
|
||||
if (!tickers.length) return;
|
||||
sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
||||
try {
|
||||
const res = await analyzeTickers(tickers);
|
||||
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
|
||||
sidebar = { open: true, loading: false, analysis: res.analysis, type,
|
||||
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') };
|
||||
} catch (e) {
|
||||
sidebar = { open: true, loading: false, analysis: null, type, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manual ticker search ─────────────────────────────────────────────────
|
||||
async function screen(): Promise<void> {
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const tickers = input.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
results = await screenTickers(tickers);
|
||||
screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Re-fetch today's catalysts ───────────────────────────────────────────
|
||||
async function reloadCatalysts(): Promise<void> {
|
||||
const { fetchCatalysts } = await import('$lib/api.js');
|
||||
loadingCats = true;
|
||||
error = null;
|
||||
try {
|
||||
const cat = await fetchCatalysts();
|
||||
input = cat.tickers.join(', ');
|
||||
loading = true;
|
||||
results = await screenTickers(cat.tickers);
|
||||
screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingCats = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = $derived(results?.marketContext ?? null);
|
||||
const allAssets = $derived(results
|
||||
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
|
||||
: []);
|
||||
// Boot — fetch catalysts + screen on mount
|
||||
let _booted = false;
|
||||
$effect(() => {
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
s.reloadCatalysts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -82,8 +28,8 @@
|
||||
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button onclick={reloadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
|
||||
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
|
||||
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => searchOpen = !searchOpen}
|
||||
@@ -92,43 +38,45 @@
|
||||
>
|
||||
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
|
||||
</button>
|
||||
{#if screenedAt}
|
||||
<span class="screened-at">Last screened {screenedAt}</span>
|
||||
{#if s.screenedAt}
|
||||
<span class="screened-at">Last screened {s.screenedAt}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchOpen}
|
||||
<div class="search-row">
|
||||
<input
|
||||
bind:value={input}
|
||||
placeholder="AAPL, MSFT, VOO …"
|
||||
onkeydown={e => e.key === 'Enter' && screen()}
|
||||
/>
|
||||
<button onclick={screen} disabled={loading || loadingCats} class="btn-screen">
|
||||
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input
|
||||
bind:value={s.input}
|
||||
placeholder="AAPL, MSFT, VOO …"
|
||||
onkeydown={e => e.key === 'Enter' && s.screen()}
|
||||
/>
|
||||
<button onclick={() => s.screen()} disabled={s.loading || s.loadingCats} class="btn-screen">
|
||||
{#if s.loading}<Spinner size="sm" />{:else}Screen{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if s.ctx}
|
||||
<MarketContextStrip ctx={s.ctx} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">⚠ {error}</div>
|
||||
{#if s.error}
|
||||
<div class="error-banner">⚠ {s.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading || loadingCats}
|
||||
{#if s.loading || s.loadingCats}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label={loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
|
||||
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ctx}
|
||||
<MarketContextStrip {ctx} />
|
||||
|
||||
{#if s.results && !s.loading && !s.loadingCats}
|
||||
<!-- ── Signal Summary ───────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Signal Summary</h2>
|
||||
<span class="count">{allAssets.length} assets</span>
|
||||
<span class="count">{s.allAssets.length} assets</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@@ -139,16 +87,21 @@
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adjusted</th>
|
||||
<th>Fundamental</th>
|
||||
<th title="Market cap tier (stocks only)">Cap</th>
|
||||
<th title="Growth / style classification (stocks only)">Style</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each allAssets as r}
|
||||
{#each s.allAssets as r}
|
||||
{@const dm = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td><span class="tag">{r.asset.type}</span></td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td><VerdictPill label={r.inflated.label} /></td>
|
||||
<td><VerdictPill label={r.fundamental.label} /></td>
|
||||
<td class="dim-cell">{dm['Cap Tier'] ?? '—'}</td>
|
||||
<td class="dim-cell">{dm['Style'] ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -158,22 +111,22 @@
|
||||
|
||||
<!-- ── Per-type detail tables ────────────────────────────────────── -->
|
||||
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
|
||||
{#if results[type]?.length}
|
||||
{#if s.results[type]?.length}
|
||||
<AssetTable
|
||||
{type}
|
||||
rows={results[type]}
|
||||
analyzeLoading={sidebar.loading && sidebar.type === type}
|
||||
onAnalyze={() => runTabAnalysis(type)}
|
||||
rows={s.results[type]}
|
||||
analyzeLoading={s.sidebar.loading && s.sidebar.type === type}
|
||||
onAnalyze={() => s.runTabAnalysis(type)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- ── Failed tickers ────────────────────────────────────────────── -->
|
||||
{#if results.ERROR?.length}
|
||||
{#if s.results.ERROR?.length}
|
||||
<section class="section">
|
||||
<h2>Failed <span class="count">{results.ERROR.length}</span></h2>
|
||||
<h2>Failed <span class="count">{s.results.ERROR.length}</span></h2>
|
||||
<div class="error-list">
|
||||
{#each results.ERROR as e}
|
||||
{#each s.results.ERROR as e}
|
||||
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -182,12 +135,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AnalysisSidebar {sidebar} onClose={() => sidebar = { ...sidebar, open: false }} />
|
||||
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
|
||||
|
||||
<style>
|
||||
.page { max-width: 1400px; padding-bottom: 60px; }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
||||
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.toolbar-top { display: flex; align-items: center; gap: 8px; }
|
||||
.search-row { display: flex; gap: 8px; align-items: center; }
|
||||
@@ -225,7 +177,8 @@
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
/* ── Error list ──────────────────────────────────────────────────── */
|
||||
.dim-cell { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
|
||||
.error-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
|
||||
.error-item { color: var(--text-dim); font-size: 12px; }
|
||||
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
|
||||
|
||||
+5
-12
@@ -1,14 +1,7 @@
|
||||
import { fetchCatalysts, screenTickers } from '$lib/api.js';
|
||||
import type { PageLoad } from './$types.js';
|
||||
|
||||
// Client-only — the API lives at localhost:3000, not accessible during SSR
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
const cat = await fetchCatalysts();
|
||||
const results = await screenTickers(cat.tickers);
|
||||
return {
|
||||
results,
|
||||
catalystInput: cat.tickers.join(', '),
|
||||
};
|
||||
};
|
||||
// Return nothing — data loading happens client-side in the page component
|
||||
// so the spinner fires on initial boot (hard refresh) too.
|
||||
export function load() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { createCall, deleteCall } from '$lib/api.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import CallForm from '$lib/calls/CallForm.svelte';
|
||||
import CallCard from '$lib/calls/CallCard.svelte';
|
||||
import CalendarSection from '$lib/calls/CalendarSection.svelte';
|
||||
import type { CalendarEvent } from '$lib/types.js';
|
||||
|
||||
interface MarketCall {
|
||||
id: string;
|
||||
@@ -16,35 +18,19 @@
|
||||
|
||||
interface PageData {
|
||||
calls: MarketCall[];
|
||||
events: unknown[];
|
||||
events: CalendarEvent[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// New call form state
|
||||
let showForm: boolean = $state(false);
|
||||
let saving: boolean = $state(false);
|
||||
let formError: string|null = $state(null);
|
||||
let form = $state({
|
||||
title: '',
|
||||
quarter: currentQuarter(),
|
||||
date: today(),
|
||||
thesis: '',
|
||||
tickers: '',
|
||||
});
|
||||
|
||||
function currentQuarter() {
|
||||
const d = new Date();
|
||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||
return `Q${q} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
async function submit(form: {
|
||||
title: string; quarter: string; date: string; thesis: string; tickers: string;
|
||||
}): Promise<void> {
|
||||
formError = null;
|
||||
saving = true;
|
||||
try {
|
||||
@@ -56,8 +42,7 @@
|
||||
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
|
||||
});
|
||||
showForm = false;
|
||||
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
|
||||
await invalidateAll(); // re-run load() to refresh the list
|
||||
await invalidateAll();
|
||||
} catch (e) {
|
||||
formError = (e as Error).message;
|
||||
} finally {
|
||||
@@ -70,316 +55,33 @@
|
||||
await deleteCall(id);
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
const signalColor = (s: string | null | undefined): string => {
|
||||
if (s?.includes('Strong')) return '#4ade80';
|
||||
if (s?.includes('Momentum')) return '#60a5fa';
|
||||
if (s?.includes('Neutral')) return '#94a3b8';
|
||||
if (s?.includes('Speculation')) return '#fb923c';
|
||||
return '#f87171';
|
||||
};
|
||||
|
||||
type EventType = 'earnings' | 'exdividend' | 'dividend';
|
||||
const eventIcon = (type: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
|
||||
const eventColor = (type: EventType): string => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
|
||||
|
||||
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
|
||||
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
|
||||
|
||||
const fmtMoney = n => n == null ? null :
|
||||
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
|
||||
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="calls-page">
|
||||
<div class="calls-page-header">
|
||||
<div>
|
||||
<h1>Market Calls</h1>
|
||||
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={() => showForm = !showForm}>
|
||||
<button class="btn-primary" onclick={() => { showForm = !showForm; formError = null; }}>
|
||||
{showForm ? 'Cancel' : '+ New Call'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── New Call Form ──────────────────────────────────────────────── -->
|
||||
{#if showForm}
|
||||
<section class="section form-section">
|
||||
<div class="section-header"><h2>New Market Call</h2></div>
|
||||
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input bind:value={form.title} placeholder="Q3 2025 — Rate pivot & tech rotation" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Quarter</span>
|
||||
<input bind:value={form.quarter} placeholder="Q3 2025" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Date</span>
|
||||
<input type="date" bind:value={form.date} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Thesis</span>
|
||||
<textarea
|
||||
bind:value={form.thesis}
|
||||
rows="4"
|
||||
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tickers to track</span>
|
||||
<input
|
||||
bind:value={form.tickers}
|
||||
placeholder="AAPL, MSFT, TLT, GLD …"
|
||||
required
|
||||
/>
|
||||
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
|
||||
</label>
|
||||
{#if formError}
|
||||
<div class="form-error">⚠ {formError}</div>
|
||||
{/if}
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{#if saving}
|
||||
<Spinner size="sm" />
|
||||
<span>Snapshotting prices…</span>
|
||||
{:else}
|
||||
Save Call
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<CallForm {saving} error={formError} onSubmit={submit} onCancel={() => showForm = false} />
|
||||
{/if}
|
||||
|
||||
<!-- ── Calendar ──────────────────────────────────────────────────── -->
|
||||
{#if (data.events ?? []).length > 0}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>📅 Upcoming Events</h2>
|
||||
<span class="count">{upcoming.length} upcoming</span>
|
||||
{#if past.length > 0}
|
||||
<span class="count" style="margin-left:4px">{past.length} recent</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cal-grid">
|
||||
{#each upcoming as ev}
|
||||
<div class="cal-event">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type" style="color:{eventColor(ev.type)}">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
|
||||
</span>
|
||||
{#if ev.epsEstimate != null}
|
||||
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if past.length > 0}
|
||||
<div class="cal-divider">— Past —</div>
|
||||
{#each past as ev}
|
||||
<div class="cal-event past">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type past-type">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<CalendarSection events={data.events ?? []} />
|
||||
|
||||
<!-- ── Calls List ────────────────────────────────────────────────── -->
|
||||
{#if data.error}
|
||||
<div class="error-banner">⚠ {data.error}</div>
|
||||
{:else if data.calls.length === 0}
|
||||
<div class="empty">No market calls yet. Create your first one to start tracking.</div>
|
||||
<div class="calls-empty">No market calls yet. Create your first one to start tracking.</div>
|
||||
{:else}
|
||||
{#each data.calls as call}
|
||||
<section class="section call-card">
|
||||
<div class="section-header">
|
||||
<div class="call-meta">
|
||||
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
|
||||
<div class="call-badges">
|
||||
<span class="tag">{call.quarter}</span>
|
||||
<span class="date-badge">{call.date}</span>
|
||||
<span class="count">{call.tickers.length} tickers</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="call-body">
|
||||
<p class="thesis">{call.thesis}</p>
|
||||
|
||||
{#if Object.keys(call.snapshot ?? {}).length}
|
||||
<div class="snapshot-grid">
|
||||
{#each call.tickers as ticker}
|
||||
{@const snap = call.snapshot[ticker]}
|
||||
{#if snap}
|
||||
<a href="/calls/{call.id}" class="snap-card">
|
||||
<div class="snap-ticker">{ticker}</div>
|
||||
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
|
||||
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
|
||||
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/calls/{call.id}" class="view-link">View performance → </a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<CallCard {call} onDelete={remove} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ── unique to this route ──────────────────────────────── */
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--fs-2xl); font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.subtitle { font-size: 12px; color: var(--text-dimmer); }
|
||||
|
||||
/* btn-delete — calls-specific icon button */
|
||||
.btn-delete { background: transparent; color: var(--text-dimmer); padding: 4px 8px; font-size: 14px; }
|
||||
.btn-delete:hover { color: var(--red); }
|
||||
|
||||
/* ── Form ────────────────────────────────────────────────────────── */
|
||||
.call-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: start; }
|
||||
.form-row .narrow { min-width: 120px; }
|
||||
|
||||
label { display: flex; flex-direction: column; gap: 5px; }
|
||||
label > span {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
padding: 9px 12px;
|
||||
font-size: var(--fs-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: var(--blue); }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
.hint { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
.form-error {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
background: var(--red-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Call card ───────────────────────────────────────────────────── */
|
||||
.call-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
|
||||
|
||||
.call-title { font-size: 14px; font-weight: 700; color: var(--text-primary); text-decoration: none; }
|
||||
.call-title:hover { color: var(--blue-muted); }
|
||||
|
||||
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.date-badge { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
.call-body { padding: var(--space-xl); display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.thesis {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid var(--blue-surface);
|
||||
padding-left: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Snapshot grid ───────────────────────────────────────────────── */
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snap-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.snap-card:hover { border-color: var(--text-faint); }
|
||||
|
||||
.snap-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
|
||||
.snap-price { font-size: var(--fs-sm); color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.snap-signal { font-size: var(--fs-xs); font-weight: 600; }
|
||||
|
||||
.view-link { font-size: 12px; color: var(--blue-muted); text-decoration: none; }
|
||||
.view-link:hover { text-decoration: underline; }
|
||||
|
||||
.empty { color: var(--text-dimmer); font-size: var(--fs-md); padding: 40px 0; text-align: center; }
|
||||
|
||||
/* ── Calendar ───────────────────────────────────────────────────── */
|
||||
.cal-grid { padding: 8px var(--space-xl) 14px; display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.cal-event {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 8px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.cal-event:hover { background: var(--bg-elevated); }
|
||||
.cal-event.past { opacity: 0.45; }
|
||||
|
||||
.cal-date { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--text-dimmer); padding-top: 1px; white-space: nowrap; }
|
||||
|
||||
.cal-content { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.cal-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
|
||||
.cal-type { font-size: var(--fs-sm); font-weight: 600; }
|
||||
.cal-detail { font-weight: 400; color: var(--text-dim); }
|
||||
.past-type { color: var(--text-dimmer) !important; }
|
||||
.cal-est { font-size: var(--fs-xs); color: var(--text-dimmer); }
|
||||
|
||||
.cal-divider {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,751 +1,55 @@
|
||||
<script lang="ts">
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { addHolding, removeHolding } from '$lib/api.js';
|
||||
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
|
||||
import type { Signal, MarketContext as MarketContextType, PortfolioHolding } from '$lib/types.js';
|
||||
import { portfolioStore } from '$lib/stores/portfolio.store.svelte.js';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import AddHoldingForm from '$lib/portfolio/AddHoldingForm.svelte';
|
||||
import AdviceTable from '$lib/portfolio/AdviceTable.svelte';
|
||||
import AccountsTable from '$lib/portfolio/AccountsTable.svelte';
|
||||
|
||||
interface AdviceRow {
|
||||
ticker: string;
|
||||
type: string;
|
||||
source: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
currentPrice: string | null;
|
||||
marketValue: string | null;
|
||||
gainLossPct: string | null;
|
||||
signal: Signal | null;
|
||||
advice: string;
|
||||
reason: string;
|
||||
}
|
||||
const p = portfolioStore;
|
||||
|
||||
interface PortfolioData {
|
||||
advice: AdviceRow[];
|
||||
marketContext: MarketContextType | null;
|
||||
personalFinance: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
let { data: _data } = $props(); // unused — we load client-side
|
||||
|
||||
let data: PortfolioData | null = $state(null);
|
||||
let loading: boolean = $state(true);
|
||||
let refreshing: boolean = $state(false);
|
||||
let loadError: string | null = $state(null);
|
||||
|
||||
// ── Add holding form (new holdings only) ────────────────────────────────────
|
||||
let formOpen: boolean = $state(false);
|
||||
let saving: boolean = $state(false);
|
||||
let formError: string|null = $state(null);
|
||||
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
|
||||
|
||||
// ── Inline row editing ───────────────────────────────────────────────────────
|
||||
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
|
||||
let inlineEdit: InlineEdit | null = $state(null);
|
||||
let inlineSaving: boolean = $state(false);
|
||||
|
||||
function startInlineEdit(a: AdviceRow) {
|
||||
inlineEdit = {
|
||||
ticker: a.ticker,
|
||||
shares: String(a.shares),
|
||||
costBasis: String(a.costBasis ?? 0),
|
||||
type: a.type ?? 'stock',
|
||||
source: a.source ?? 'Robinhood',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveInlineEdit() {
|
||||
if (!inlineEdit) return;
|
||||
inlineSaving = true;
|
||||
try {
|
||||
const updated = {
|
||||
ticker: inlineEdit.ticker,
|
||||
shares: parseFloat(inlineEdit.shares),
|
||||
costBasis: parseFloat(inlineEdit.costBasis) || 0,
|
||||
type: inlineEdit.type,
|
||||
source: inlineEdit.source,
|
||||
};
|
||||
await addHolding(updated);
|
||||
|
||||
// Optimistic update — patch the row immediately, don't wait for Yahoo
|
||||
if (data?.advice) {
|
||||
data = {
|
||||
...data,
|
||||
advice: data.advice.map(a =>
|
||||
a.ticker === updated.ticker
|
||||
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
|
||||
marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)),
|
||||
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
|
||||
: a
|
||||
),
|
||||
};
|
||||
}
|
||||
inlineEdit = null;
|
||||
fetchPortfolioData(false); // background: update prices + signals
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
} finally {
|
||||
inlineSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = !formOpen;
|
||||
formError = null;
|
||||
inlineEdit = null;
|
||||
}
|
||||
|
||||
async function submitHolding() {
|
||||
formError = null;
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const shares = parseFloat(form.shares);
|
||||
const costBasis = parseFloat(form.costBasis) || 0;
|
||||
if (!ticker) { formError = 'Ticker is required.'; return; }
|
||||
if (!shares || shares <= 0) { formError = 'Shares must be greater than 0.'; return; }
|
||||
saving = true;
|
||||
try {
|
||||
await addHolding({ ticker, shares, costBasis, type: form.type, source: form.source });
|
||||
|
||||
// Optimistic update — add placeholder row immediately
|
||||
const existing = data?.advice?.find(a => a.ticker === ticker);
|
||||
if (data?.advice && !existing) {
|
||||
data = {
|
||||
...data,
|
||||
advice: [...data.advice, {
|
||||
ticker, shares, costBasis, type: form.type, source: form.source,
|
||||
currentPrice: null, marketValue: null, gainLossPct: null,
|
||||
signal: null, advice: '⏳ Fetching…', reason: 'Screener data loading in background.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = false;
|
||||
fetchPortfolioData(false); // background: get real price + signal
|
||||
} catch (e) {
|
||||
formError = (e as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHolding(ticker: string): Promise<void> {
|
||||
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
|
||||
// Optimistic remove — drop the row immediately
|
||||
if (data?.advice) {
|
||||
data = { ...data, advice: data.advice.filter(a => a.ticker !== ticker) };
|
||||
}
|
||||
try {
|
||||
await removeHolding(ticker);
|
||||
fetchPortfolioData(false); // background: recalculate totals
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPortfolioData(showFullSpinner = false) {
|
||||
if (showFullSpinner) loading = true;
|
||||
else refreshing = true;
|
||||
loadError = null;
|
||||
fetch('/api/finance/portfolio')
|
||||
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
|
||||
.then(json => { data = json; })
|
||||
.catch(e => { loadError = (e as Error).message; })
|
||||
.finally(() => { loading = false; refreshing = false; });
|
||||
}
|
||||
let { data: _data } = $props();
|
||||
|
||||
let _booted = false;
|
||||
$effect(() => {
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
fetchPortfolioData(true); // initial load — show full spinner
|
||||
p.fetch(true);
|
||||
});
|
||||
|
||||
// ── Table sorting ────────────────────────────────────────────────────────────
|
||||
let sortCol = $state('ticker');
|
||||
let sortDir = $state(1); // 1 = asc, -1 = desc
|
||||
|
||||
function toggleSort(col: string): void {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
}
|
||||
|
||||
const sortedAdvice = $derived.by(() => {
|
||||
if (!data?.advice) return [];
|
||||
return [...data.advice].sort((a, b) => {
|
||||
let av, bv;
|
||||
switch (sortCol) {
|
||||
case 'ticker': av = a.ticker; bv = b.ticker; break;
|
||||
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
|
||||
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
|
||||
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
|
||||
case 'current': av = parseFloat(a.currentPrice) || 0; bv = parseFloat(b.currentPrice) || 0; break;
|
||||
case 'value': av = parseFloat(a.marketValue) || 0; bv = parseFloat(b.marketValue) || 0; break;
|
||||
case 'gl': av = parseFloat(a.gainLossPct) || 0; bv = parseFloat(b.gainLossPct) || 0; break;
|
||||
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
|
||||
default: return 0;
|
||||
}
|
||||
if (av < bv) return -sortDir;
|
||||
if (av > bv) return sortDir;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
const sortIcon = (col: string): string => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
|
||||
|
||||
|
||||
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
|
||||
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
|
||||
const totalGL = $derived(totalValue - totalCost);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if loading}
|
||||
<div class="portfolio-page">
|
||||
{#if p.loading}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label="Loading portfolio…" />
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="error">{loadError}</div>
|
||||
{:else if p.loadError}
|
||||
<div class="error">{p.loadError}</div>
|
||||
|
||||
{:else if data?.advice}
|
||||
<!-- ── Toolbar ──────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<button class="btn-add" onclick={openAdd}>
|
||||
{formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
{:else if p.data?.advice}
|
||||
<div class="portfolio-toolbar">
|
||||
<button class="btn-add" onclick={() => p.formOpen ? p.closeForm() : p.openForm()}>
|
||||
{p.formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
</button>
|
||||
{#if refreshing}
|
||||
{#if p.refreshing}
|
||||
<span class="refreshing-hint">Updating prices…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Add Holding Form ─────────────────────────────────────── -->
|
||||
{#if formOpen}
|
||||
<div class="add-form">
|
||||
<div class="form-title">Add Holding</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="form-ticker">Ticker</label>
|
||||
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-shares">Shares</label>
|
||||
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-cost">Cost Basis / share</label>
|
||||
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-type">Type</label>
|
||||
<select id="form-type" bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
<option value="crypto">Crypto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-source">Source</label>
|
||||
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-save" onclick={submitHolding} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{#if formError}
|
||||
<div class="form-error">⚠ {formError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if p.formOpen}
|
||||
<AddHoldingForm saving={p.saving} error={p.formError} onSubmit={d => p.add(d)} onClose={() => p.closeForm()} />
|
||||
{/if}
|
||||
|
||||
{#if data.marketContext}
|
||||
<MarketContext ctx={data.marketContext} collapsible={true} />
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
|
||||
<!-- P&L Summary -->
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Value</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalValue)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Cost</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalCost)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total G/L</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
|
||||
<!-- Holdings -->
|
||||
<section class="card-section">
|
||||
<h2>Holdings — Hold / Sell / Add Advice</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
|
||||
<th>Advice</th><th>Reason</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedAdvice as a}
|
||||
{@const isEditing = inlineEdit?.ticker === a.ticker}
|
||||
<tr class:editing={isEditing}>
|
||||
<td class="ticker">{a.ticker}</td>
|
||||
<td>
|
||||
{#if isEditing}
|
||||
<select class="inline-select" bind:value={inlineEdit.type}>
|
||||
<option value="stock">stock</option>
|
||||
<option value="etf">etf</option>
|
||||
<option value="bond">bond</option>
|
||||
<option value="crypto">crypto</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="tag">{a.type}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.shares} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{a.shares}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.costBasis} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{fmt(a.costBasis)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{fmt(parseFloat(a.currentPrice))}</td>
|
||||
<td class="num">{fmt(parseFloat(a.marketValue))}</td>
|
||||
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray">—</span>{/if}</td>
|
||||
<td class={advClass(a.advice)}>{a.advice}</td>
|
||||
<td class="reason">{a.reason}</td>
|
||||
<td class="row-actions">
|
||||
{#if isEditing}
|
||||
<button class="btn-save-inline" onclick={saveInlineEdit} disabled={inlineSaving}>
|
||||
{inlineSaving ? '…' : '✓'}
|
||||
</button>
|
||||
<button class="btn-cancel-inline" onclick={() => inlineEdit = null}>✕</button>
|
||||
{:else}
|
||||
<button class="btn-edit" onclick={() => startInlineEdit(a)} title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick={() => deleteHolding(a.ticker)} title="Remove">✕</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Personal Finance (SimpleFIN) -->
|
||||
{#if data.personalFinance}
|
||||
{@const pf = data.personalFinance}
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel">Net Worth</div>
|
||||
<div class="svalue {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Total Assets</div>
|
||||
<div class="svalue">{fmtShort(pf.totalAssets)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Liabilities</div>
|
||||
<div class="svalue red">{fmtShort(pf.totalLiabilities)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Cash ({pf.cashPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalCash)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Investments ({pf.investPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalInvestments)}</div>
|
||||
</div>
|
||||
{#if pf.savingsRate != null}
|
||||
<div class="scard">
|
||||
<div class="slabel">Savings Rate</div>
|
||||
<div class="svalue {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Income</div>
|
||||
<div class="svalue">{fmtShort(pf.totalIncome)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Spend</div>
|
||||
<div class="svalue">{fmtShort(pf.totalSpend)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="card-section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.accounts as a}
|
||||
<tr>
|
||||
<td class="ticker">{a.name}</td>
|
||||
<td><span class="tag">{a.type}</span></td>
|
||||
<td class="gray">{a.org}</td>
|
||||
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card-section">
|
||||
<h2>Spending — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.categoryBreakdown.slice(0, 10) as c}
|
||||
<tr>
|
||||
<td>{c.category}</td>
|
||||
<td class="num right">{fmt(c.amount)}</td>
|
||||
<td class="num right gray">{c.pct}%</td>
|
||||
<td style="width:100px">
|
||||
<div class="bar-bg">
|
||||
<div class="bar-fill" style="width:{Math.min(c.pct,100)}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ── unique to this route ──────────────────────────────── */
|
||||
.page { max-width: 1400px; }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
||||
.toolbar { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
/* btn-add = primary button style (same as btn-primary) */
|
||||
.btn-add {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 9px 18px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-add:hover { background: var(--blue-darker); }
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
|
||||
/* ── Add holding form ────────────────────────────────────────────── */
|
||||
.add-form {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dimmer);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.field label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
.field input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.field input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--fs-md);
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field input:focus { border-color: var(--blue); }
|
||||
|
||||
.field select {
|
||||
background: var(--bg-card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 10px center;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 32px 8px 12px;
|
||||
font-size: var(--fs-md);
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field select:focus { border-color: var(--blue); }
|
||||
|
||||
.btn-save {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 20px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--blue-darker); }
|
||||
.btn-save:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.form-error { color: var(--red); font-size: 12px; margin-top: 10px; }
|
||||
|
||||
/* ── Inline row editing ──────────────────────────────────────────── */
|
||||
tr.editing { background: var(--blue-badge); }
|
||||
|
||||
.inline-input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
}
|
||||
.inline-input:focus { border-color: var(--blue); }
|
||||
|
||||
.inline-select {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: var(--fs-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-save-inline {
|
||||
background: #14532d55;
|
||||
border: none;
|
||||
color: var(--green);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-save-inline:hover:not(:disabled) { background: #14532d99; }
|
||||
.btn-save-inline:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-cancel-inline:hover { color: var(--text-muted); }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.btn-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-edit:hover { color: var(--blue-muted); background: var(--blue-deep); }
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-delete:hover { color: var(--red); background: var(--red-bg); }
|
||||
|
||||
/* ── Loading / error ─────────────────────────────────────────────── */
|
||||
.loading-area { display: flex; justify-content: center; align-items: center; padding: 100px 0; }
|
||||
.error { color: var(--red); background: var(--red-bg); border-radius: var(--radius-md); padding: 10px var(--space-lg); }
|
||||
|
||||
/* ── P&L summary grid ────────────────────────────────────────────── */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scard { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); }
|
||||
|
||||
.slabel-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.slabel { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.svalue { font-size: var(--fs-xl); font-weight: 700; color: var(--text-primary); margin-top: 4px; }
|
||||
|
||||
/* ── Summary card tooltips ───────────────────────────────────────── */
|
||||
.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
|
||||
.stip-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--text-faint);
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-2xs);
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.stip-box {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
.stip-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--text-faint);
|
||||
}
|
||||
.stip-wrap:hover .stip-box { display: block; }
|
||||
|
||||
/* ── Card section (portfolio tables) ────────────────────────────── */
|
||||
.card-section {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-section h2 {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Portfolio table overrides (full-width, tighter padding) */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dimmer);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid var(--bg-row-alt); }
|
||||
tbody tr:hover { background: #1e293b55; }
|
||||
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
|
||||
|
||||
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
th.sortable:hover { color: var(--text-muted); }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 14px; color: var(--text-primary); }
|
||||
.num { font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||||
.reason { color: var(--text-muted); font-size: var(--fs-sm); white-space: normal; max-width: 260px; }
|
||||
.right { text-align: right; }
|
||||
|
||||
/* Signal colour classes (used via glClass / advClass helpers) */
|
||||
.green { color: var(--green); font-weight: 600; }
|
||||
.yellow { color: var(--yellow); font-weight: 600; }
|
||||
.orange { color: var(--orange); font-weight: 600; }
|
||||
.red { color: var(--red); font-weight: 600; }
|
||||
.gray { color: var(--text-dim); }
|
||||
|
||||
.bar-bg { background: var(--bg-card); border-radius: var(--radius-xs); height: 6px; }
|
||||
.bar-fill { background: var(--blue); border-radius: var(--radius-xs); height: 6px; }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// ── Calls route — page, CallForm, CallCard, CalendarSection ──────────────
|
||||
|
||||
// ── calls/+page.svelte ────────────────────────────────────────────────────
|
||||
|
||||
.calls-page {
|
||||
max-width: 1100px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.calls-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle { font-size: 12px; color: var(--text-dimmer); }
|
||||
}
|
||||
|
||||
.calls-empty {
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ── CallForm ──────────────────────────────────────────────────────────────
|
||||
|
||||
.call-form {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
|
||||
.narrow { min-width: 120px; }
|
||||
}
|
||||
|
||||
.call-hint { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
.call-form-actions { display: flex; gap: 10px; align-items: center; }
|
||||
|
||||
// ── CallCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
.call-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.call-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--blue-muted); }
|
||||
}
|
||||
|
||||
.call-card-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.call-date-badge { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
.call-card-body {
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.call-thesis {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid var(--blue-surface);
|
||||
padding-left: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snap-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--transition);
|
||||
|
||||
&:hover { border-color: var(--text-faint); }
|
||||
}
|
||||
|
||||
.snap-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
|
||||
.snap-price { font-size: var(--fs-sm); color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.snap-signal { font-size: var(--fs-xs); font-weight: 600; }
|
||||
|
||||
.call-view-link {
|
||||
font-size: 12px;
|
||||
color: var(--blue-muted);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.btn-call-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--red); }
|
||||
}
|
||||
|
||||
// ── CalendarSection ───────────────────────────────────────────────────────
|
||||
|
||||
.cal-grid {
|
||||
padding: 8px var(--space-xl) 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-event {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 8px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover { background: var(--bg-elevated); }
|
||||
&.past { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.cal-date { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--text-dimmer); padding-top: 1px; white-space: nowrap; }
|
||||
.cal-content { display: flex; flex-direction: column; gap: 2px; }
|
||||
.cal-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
|
||||
.cal-type { font-size: var(--fs-sm); font-weight: 600; }
|
||||
.cal-detail { font-weight: 400; color: var(--text-dim); }
|
||||
.past-type { color: var(--text-dimmer) !important; }
|
||||
.cal-est { font-size: var(--fs-xs); color: var(--text-dimmer); }
|
||||
|
||||
.cal-divider {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// ── Shared form field styles ──────────────────────────────────────────────
|
||||
// Used by both portfolio (AddHoldingForm) and calls (CallForm).
|
||||
|
||||
// ── Field + label ─────────────────────────────────────────────────────────
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
label,
|
||||
> span {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
input::placeholder { color: var(--text-faint); }
|
||||
}
|
||||
|
||||
// ── Shared input / select / textarea ─────────────────────────────────────
|
||||
|
||||
%form-control {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--fs-md);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition);
|
||||
|
||||
&:focus { border-color: var(--blue); }
|
||||
}
|
||||
|
||||
.field input { @extend %form-control; }
|
||||
.field select {
|
||||
@extend %form-control;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 32px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// ── Call-form inputs (slightly different padding) ─────────────────────────
|
||||
|
||||
.call-form {
|
||||
input,
|
||||
textarea {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
padding: 9px 12px;
|
||||
font-size: var(--fs-md);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus { border-color: var(--blue); }
|
||||
}
|
||||
|
||||
textarea { resize: vertical; height: auto; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
> span {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared form-error ─────────────────────────────────────────────────────
|
||||
|
||||
.form-error {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.form-error-block {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
background: var(--red-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
// ── Inline save/cancel button pair (portfolio table rows) ─────────────────
|
||||
|
||||
.btn-save-inline {
|
||||
background: #14532d55;
|
||||
border: none;
|
||||
color: var(--green);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-weight: 700;
|
||||
|
||||
&:hover:not(:disabled) { background: #14532d99; }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-cancel-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--text-muted); }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// ── Portfolio route — page, AddHoldingForm, AdviceTable, AccountsTable ────
|
||||
|
||||
// ── portfolio/+page.svelte ────────────────────────────────────────────────
|
||||
|
||||
.portfolio-page { max-width: 1400px; }
|
||||
|
||||
.portfolio-toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 9px 18px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: var(--blue-darker); }
|
||||
}
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
animation: portfolio-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes portfolio-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
|
||||
// ── AddHoldingForm ────────────────────────────────────────────────────────
|
||||
|
||||
.add-form {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.add-form-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dimmer);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.add-form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.btn-form-save {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 20px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
height: 38px;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--blue-darker); }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-form-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-end;
|
||||
height: 38px;
|
||||
|
||||
&:hover { color: var(--text-muted); }
|
||||
}
|
||||
|
||||
// ── AdviceTable — P&L summary ─────────────────────────────────────────────
|
||||
|
||||
.pnl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pnl-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px var(--space-lg);
|
||||
}
|
||||
|
||||
.pnl-label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.pnl-label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.pnl-value { font-size: var(--fs-xl); font-weight: 700; color: var(--text-primary); margin-top: 4px; }
|
||||
|
||||
// ── Summary card tooltip ──────────────────────────────────────────────────
|
||||
|
||||
.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
|
||||
.stip-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--text-faint);
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-2xs);
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.stip-box {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
.stip-wrap:hover .stip-box { display: block; }
|
||||
|
||||
// ── AdviceTable — holdings table ──────────────────────────────────────────
|
||||
|
||||
.advice-section {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.advice-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dimmer);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: var(--text-muted); }
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--bg-row-alt);
|
||||
&:hover { background: #1e293b55; }
|
||||
&.editing { background: var(--blue-badge); }
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 9px 10px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.advice-row-actions { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.btn-row-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--blue-muted); background: var(--blue-deep); }
|
||||
}
|
||||
|
||||
.btn-row-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--red); background: var(--red-bg); }
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: var(--blue); }
|
||||
}
|
||||
|
||||
.inline-select {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: var(--fs-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// ── AccountsTable — personal finance ─────────────────────────────────────
|
||||
|
||||
.accounts-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.accounts-section {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dimmer);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tbody tr { border-bottom: 1px solid var(--bg-row-alt); }
|
||||
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.spend-bar-bg { background: var(--bg-card); border-radius: var(--radius-xs); height: 6px; }
|
||||
.spend-bar-fill { background: var(--blue); border-radius: var(--radius-xs); height: 6px; }
|
||||
@@ -0,0 +1,155 @@
|
||||
// ── AnalysisSidebar — slide-over panel ────────────────────────────────────
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #00000055;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: 380px;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--blue-surface);
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--blue-badge);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-type {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--blue-surface);
|
||||
color: var(--blue-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
|
||||
&:hover { color: var(--text-muted); background: var(--bg-card); }
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.sidebar-error {
|
||||
color: var(--red);
|
||||
background: var(--red-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px var(--space-lg);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
// ── Sidebar content blocks ────────────────────────────────────────────────
|
||||
|
||||
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.sb-summary {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid var(--blue-surface);
|
||||
padding-left: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-sub {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dimmer);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sb-ticker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sb-chips { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.sb-chip {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.sb-bias[data-bias="BULL"] { background: var(--green-bg, #0d2a1a); color: var(--green, #4ade80); }
|
||||
.sb-bias[data-bias="BEAR"] { background: var(--red-bg, #2a0d0d); color: var(--red, #f87171); }
|
||||
|
||||
.sb-horizon { background: var(--blue-badge); color: var(--blue-muted); }
|
||||
.sb-sensitivity { background: var(--bg-card); color: var(--text-dimmer); border: 1px solid var(--border); }
|
||||
|
||||
.sb-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sb-reason {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -9,3 +9,7 @@
|
||||
@use 'table';
|
||||
@use 'buttons';
|
||||
@use 'badges';
|
||||
@use 'forms';
|
||||
@use 'sidebar';
|
||||
@use 'calls';
|
||||
@use 'portfolio';
|
||||
|
||||
+8
-1
@@ -1,5 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
export default {
|
||||
kit: { adapter: adapter() },
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
// $types → server/types/ — lets UI import shared domain types without duplication.
|
||||
// SvelteKit auto-wires this into both Vite's resolve.alias and the generated tsconfig.
|
||||
alias: {
|
||||
$types: '../server/types',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+8
-1
@@ -4,6 +4,13 @@
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"$lib": ["./src/lib"],
|
||||
"$lib/*": ["./src/lib/*"],
|
||||
"$app/types": ["./.svelte-kit/types/index.d.ts"],
|
||||
"$types": ["../server/types"],
|
||||
"$types/*": ["../server/types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user