phase-7: code restructure

This commit is contained in:
Kazuma
2026-06-05 22:05:55 -04:00
parent 69d13c3dbe
commit 73db0fe7a8
108 changed files with 8931 additions and 3434 deletions
+58 -105
View File
@@ -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
View File
@@ -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 {};
}
+16 -314
View File
@@ -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>
+25 -721
View File
@@ -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>