Files
Neon-Desk/app/portfolio/page.tsx

574 lines
27 KiB
TypeScript

'use client';
import { useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
import { BrainCircuit, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
deleteHolding,
queuePortfolioInsights,
queuePriceRefresh,
updateHolding,
upsertHolding
} from '@/lib/api';
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import {
holdingsQueryOptions,
latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions
} from '@/lib/query/options';
type FormState = {
ticker: string;
companyName: string;
shares: string;
avgCost: string;
currentPrice: string;
};
const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c'];
const CHART_TEXT = '#e8fff8';
const CHART_MUTED = '#b4ced9';
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
const EMPTY_SUMMARY: PortfolioSummary = {
positions: 0,
total_value: '0',
total_gain_loss: '0',
total_cost_basis: '0',
avg_return_pct: '0'
};
export default function PortfolioPage() {
const { isPending, isAuthenticated } = useAuthGuard();
const queryClient = useQueryClient();
const { prefetchResearchTicker } = useLinkPrefetch();
const [holdings, setHoldings] = useState<Holding[]>([]);
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingHoldingId, setEditingHoldingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
const loadPortfolio = useCallback(async () => {
const holdingsOptions = holdingsQueryOptions();
const summaryOptions = portfolioSummaryQueryOptions();
const insightOptions = latestPortfolioInsightQueryOptions();
if (!queryClient.getQueryData(summaryOptions.queryKey)) {
setLoading(true);
}
setError(null);
try {
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
queryClient.fetchQuery(holdingsOptions),
queryClient.fetchQuery(summaryOptions),
queryClient.fetchQuery(insightOptions)
]);
setHoldings(holdingsRes.holdings);
setSummary(summaryRes.summary);
setLatestInsight(insightRes.insight);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not fetch portfolio data');
} finally {
setLoading(false);
}
}, [queryClient]);
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadPortfolio();
}
}, [isPending, isAuthenticated, loadPortfolio]);
const allocationData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
value: asNumber(holding.market_value)
})),
[holdings]
);
const performanceData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
value: asNumber(holding.gain_loss_pct)
})),
[holdings]
);
const resetHoldingForm = useCallback(() => {
setEditingHoldingId(null);
setForm({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
}, []);
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
if (editingHoldingId === null) {
await upsertHolding({
ticker: form.ticker.toUpperCase(),
companyName: form.companyName.trim() || undefined,
shares: Number(form.shares),
avgCost: Number(form.avgCost),
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
});
} else {
await updateHolding(editingHoldingId, {
companyName: form.companyName.trim() || undefined,
shares: Number(form.shares),
avgCost: Number(form.avgCost),
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
});
}
resetHoldingForm();
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save holding');
}
};
const queueRefresh = async () => {
try {
await queuePriceRefresh();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
}
};
const queueInsights = async () => {
try {
await queuePortfolioInsights();
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
}
};
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading portfolio matrix...</div>;
}
return (
<AppShell
title="Portfolio"
subtitle="Position management, market valuation, and AI generated portfolio commentary."
actions={(
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
<RefreshCcw className="size-4" />
Queue price refresh
</Button>
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
<BrainCircuit className="size-4" />
Generate AI brief
</Button>
</div>
)}
>
{error ? (
<Panel variant="surface">
<p className="text-sm text-[#ffb5b5]">{error}</p>
</Panel>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Panel title="Total Value" className="lg:col-span-1">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(summary.total_value)}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Cost basis {formatCurrency(summary.total_cost_basis)}</p>
</Panel>
<Panel title="Unrealized P&L" className="lg:col-span-1">
<p className={`text-3xl font-semibold ${asNumber(summary.total_gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
{formatCurrency(summary.total_gain_loss)}
</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Average return {formatPercent(summary.avg_return_pct)}</p>
</Panel>
<Panel title="Positions" className="lg:col-span-1">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{summary.positions}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Active symbols in portfolio.</p>
</Panel>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Panel title="Allocation" variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : allocationData.length > 0 ? (
<div className="h-[260px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
{allocationData.map((entry, index) => (
<Cell key={`${entry.name}-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value: number | string | undefined) => formatCurrency(value)}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
/>
<Legend
verticalAlign="bottom"
iconType="circle"
wrapperStyle={{ paddingTop: '0.75rem' }}
formatter={(value) => <span style={{ color: CHART_TEXT }}>{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings yet.</p>
)}
</Panel>
<Panel title="Performance %" variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : performanceData.length > 0 ? (
<div className="h-[260px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={performanceData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="name"
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
/>
<Tooltip
formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }}
/>
<Bar dataKey="value" fill="#68ffd5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No performance data yet.</p>
)}
</Panel>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.5fr_1fr]">
<Panel title="Holdings Table" subtitle="Live mark-to-market values from latest refresh." variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading holdings...</p>
) : holdings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
) : (
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{holdings.map((holding) => (
<article key={holding.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{holding.ticker}</p>
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{holding.company_name ?? 'Company name unavailable'}</p>
</div>
<p className={`text-sm font-medium ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
{formatCurrency(holding.gain_loss)}
</p>
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Shares</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asNumber(holding.shares).toLocaleString()}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Avg Cost</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.avg_cost)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Price</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Value</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.market_value)}</dd>
</div>
</dl>
<p className={`mt-3 text-xs ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
Return {formatPercent(holding.gain_loss_pct)}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={`/analysis?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Analysis
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Financials
</Link>
<Link
href={`/filings?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Filings
</Link>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setEditingHoldingId(holding.id);
setForm({
ticker: holding.ticker,
companyName: holding.company_name ?? '',
shares: String(asNumber(holding.shares)),
avgCost: String(asNumber(holding.avg_cost)),
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
});
}}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
if (editingHoldingId === holding.id) {
resetHoldingForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
</article>
))}
</div>
<div className="data-table-wrap hidden max-w-full lg:block">
<table className="data-table min-w-[1020px]">
<thead>
<tr>
<th>Ticker</th>
<th>Company</th>
<th>Shares</th>
<th>Avg Cost</th>
<th>Price</th>
<th>Value</th>
<th>Gain/Loss</th>
<th>Research</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{holdings.map((holding) => (
<tr key={holding.id}>
<td>{holding.ticker}</td>
<td>{holding.company_name ?? 'n/a'}</td>
<td>{asNumber(holding.shares).toLocaleString()}</td>
<td>{formatCurrency(holding.avg_cost)}</td>
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
<td>{formatCurrency(holding.market_value)}</td>
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
</td>
<td>
<div className="flex flex-wrap gap-2">
<Link
href={`/analysis?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Analysis
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Financials
</Link>
<Link
href={`/filings?ticker=${holding.ticker}`}
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Filings
</Link>
</div>
</td>
<td>
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setEditingHoldingId(holding.id);
setForm({
ticker: holding.ticker,
companyName: holding.company_name ?? '',
shares: String(asNumber(holding.shares)),
avgCost: String(asNumber(holding.avg_cost)),
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
});
}}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
await loadPortfolio();
if (editingHoldingId === holding.id) {
resetHoldingForm();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'} variant="surface">
<form onSubmit={submitHolding} className="space-y-3">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
<Input
value={form.ticker}
aria-label="Holding ticker"
disabled={editingHoldingId !== null}
onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))}
required
/>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Company Name (optional)</label>
<Input
value={form.companyName}
aria-label="Holding company name"
onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))}
placeholder="Resolved from coverage or filings if left blank"
/>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Shares</label>
<Input aria-label="Holding shares" type="number" step="0.0001" min="0.0001" value={form.shares} onChange={(event) => setForm((prev) => ({ ...prev, shares: event.target.value }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Average Cost</label>
<Input aria-label="Holding average cost" type="number" step="0.0001" min="0.0001" value={form.avgCost} onChange={(event) => setForm((prev) => ({ ...prev, avgCost: event.target.value }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" className="w-full sm:flex-1">
<Plus className="size-4" />
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
</Button>
{editingHoldingId !== null ? (
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
Cancel
</Button>
) : null}
</div>
</form>
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Latest AI Insight</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">
{latestInsight?.content ?? 'No insight available yet. Queue an AI brief from the header.'}
</p>
</div>
</Panel>
</div>
</AppShell>
);
}