Files
market_screener/ui/src/lib/portfolio/AdviceTable.svelte
T
2026-06-06 22:55:43 -04:00

187 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>