Investment portfolio charts, search fix, and holding creation fixes

- Add four portfolio charts: allocation donut by holding, allocation
  donut by asset type, cost basis vs current value bar, return % bar
- Fix asset search to use yf.Search() full-text instead of ticker-only
  lookup — name searches like "vanguard ftse all world" now work
- Fix holding creation double-quantity bug: holdings now created with
  quantity=0 so buy transaction is sole source of quantity/cost basis
- Add per-share / total price toggle in Add Holding modal with live
  calculated equivalent shown as you type
- Add ErrorBoundary in AppShell so render errors show a message instead
  of a blank page
- Fix donut charts using || instead of ?? when falling back from
  current_value to cost_basis_total (0 was not falling through ??)
- Allow HoldingCreate.quantity >= 0 (was gt=0) to support zero-init
- Fix error display for Pydantic v2 array-of-objects validation errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 23:06:41 +00:00
parent 26e2a055db
commit cdc1e67321
9 changed files with 424 additions and 70 deletions

View file

@ -90,6 +90,11 @@ backend/app/
- Soft deletes: `deleted_at` timestamp; all queries must filter `WHERE deleted_at IS NULL` - Soft deletes: `deleted_at` timestamp; all queries must filter `WHERE deleted_at IS NULL`
- `_to_response()` in `transaction_service.py` must include all fields returned to the frontend — omitting a field here makes it invisible to the UI even if it's in the DB - `_to_response()` in `transaction_service.py` must include all fields returned to the frontend — omitting a field here makes it invisible to the UI even if it's in the DB
### Investment data model
- Asset search uses `yf.Search(query)` (full-text, name or ticker) in `price_feed_service.search_yahoo()` — not ticker-only lookup
- `HoldingCreate.quantity` allows `ge=0` (zero) — holdings are created with quantity=0, and `add_investment_transaction()` drives the quantity via cumulative buy/sell updates. Never create a holding with the target quantity and also add a matching buy transaction, or the quantity doubles
- `PortfolioCharts.tsx` uses `||` (not `??`) when falling back from `current_value` to `cost_basis_total``??` fails when `current_value` is `0` rather than null
### AI / receipt parsing (`api/v1/settings.py`, `api/v1/transactions.py`) ### AI / receipt parsing (`api/v1/settings.py`, `api/v1/transactions.py`)
- User AI config (provider, encrypted API key, base URL, model, debug flag) lives on the `users` table; managed via `GET/PUT/DELETE /settings/ai` - User AI config (provider, encrypted API key, base URL, model, debug flag) lives on the `users` table; managed via `GET/PUT/DELETE /settings/ai`
- `ai_api_key_enc` is AES-256-GCM encrypted with `encrypt_field`/`decrypt_field` - `ai_api_key_enc` is AES-256-GCM encrypted with `encrypt_field`/`decrypt_field`

View file

@ -26,12 +26,13 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w
### Investments ### Investments
- Portfolio tracking with holdings and buy/sell/dividend/split transactions - Portfolio tracking with holdings and buy/sell/dividend/split transactions
- Asset search by name or ticker (stocks, ETFs, crypto, funds) via **yfinance** full-text search
- Live price feeds: stocks and ETFs via **yfinance**, crypto via **CoinGecko** (refreshed every 15 minutes) - Live price feeds: stocks and ETFs via **yfinance**, crypto via **CoinGecko** (refreshed every 15 minutes)
- Hourly FX rates for multi-currency conversion to GBP base - Hourly FX rates for multi-currency conversion to GBP base
- TWRR and MWRR performance metrics - TWRR and MWRR performance metrics
- Capital gains reporting (short/long-term by tax year) - Capital gains reporting (short/long-term by tax year)
- OHLCV candlestick charts per asset - OHLCV candlestick charts per asset
- Allocation treemap across the full portfolio - Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart
### Reports ### Reports
Seven report views: Seven report views:

View file

@ -36,7 +36,7 @@ class AssetPricePoint(BaseModel):
class HoldingCreate(BaseModel): class HoldingCreate(BaseModel):
account_id: uuid.UUID account_id: uuid.UUID
asset_id: uuid.UUID asset_id: uuid.UUID
quantity: Decimal = Field(..., gt=0) quantity: Decimal = Field(..., ge=0)
avg_cost_basis: Decimal = Field(..., ge=0) avg_cost_basis: Decimal = Field(..., ge=0)
currency: str = Field(default="GBP", min_length=3, max_length=10) currency: str = Field(default="GBP", min_length=3, max_length=10)

View file

@ -98,19 +98,26 @@ async def fetch_history(symbol: str, days: int = 365) -> list[dict]:
def search_yahoo(query: str) -> list[dict]: def search_yahoo(query: str) -> list[dict]:
try: try:
import yfinance as yf import yfinance as yf
ticker = yf.Ticker(query) results = yf.Search(query, max_results=10).quotes
info = ticker.fast_info out = []
price = getattr(info, "last_price", None) for r in results:
if price: symbol = r.get("symbol")
return [{ if not symbol:
"symbol": query.upper(), continue
"name": getattr(info, "long_name", None) or query.upper(), quote_type = (r.get("quoteType") or r.get("typeDisp") or "stock").lower()
"type": "stock", type_map = {"etf": "etf", "equity": "stock", "cryptocurrency": "crypto",
"currency": (getattr(info, "currency", None) or "USD").upper(), "mutualfund": "fund", "bond": "bond"}
"exchange": getattr(info, "exchange", None), asset_type = type_map.get(quote_type, "stock")
out.append({
"symbol": symbol,
"name": r.get("longname") or r.get("shortname") or symbol,
"type": asset_type,
"currency": "USD", # updated on first price sync
"exchange": r.get("exchDisp") or r.get("exchange"),
"data_source": "yahoo_finance", "data_source": "yahoo_finance",
"data_source_id": None, "data_source_id": None,
}] })
return out
except Exception: except Exception:
pass pass
return [] return []

View file

@ -0,0 +1,37 @@
import { Component, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
interface Props { children: ReactNode; }
interface State { error: Error | null; }
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
render() {
if (this.state.error) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-8 text-center">
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-destructive" />
</div>
<div>
<p className="font-semibold text-lg">Something went wrong</p>
<p className="text-sm text-muted-foreground mt-1 font-mono">{this.state.error.message}</p>
</div>
<button
onClick={() => { this.setState({ error: null }); window.location.reload(); }}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<RefreshCw className="w-4 h-4" />
Reload page
</button>
</div>
);
}
return this.props.children;
}
}

View file

@ -2,6 +2,7 @@ import { useUiStore } from "@/store/uiStore";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import TopBar from "./TopBar"; import TopBar from "./TopBar";
import MobileNav from "./MobileNav"; import MobileNav from "./MobileNav";
import ErrorBoundary from "@/components/ErrorBoundary";
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
@ -25,7 +26,7 @@ export default function AppShell({ children }: AppShellProps) {
<TopBar /> <TopBar />
{/* Extra bottom padding on mobile so content clears the nav bar */} {/* Extra bottom padding on mobile so content clears the nav bar */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8"> <main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
{children} <ErrorBoundary>{children}</ErrorBoundary>
</main> </main>
</div> </div>

View file

@ -27,6 +27,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
["investment", "pension", "savings", "other"].includes(a.type) ["investment", "pension", "savings", "other"].includes(a.type)
); );
const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
const [form, setForm] = useState({ const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "", account_id: investAccounts[0]?.id ?? "",
quantity: "", quantity: "",
@ -57,12 +58,13 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
setError(null); setError(null);
try { try {
const qty = parseFloat(form.quantity); const qty = parseFloat(form.quantity);
const price = parseFloat(form.price); const rawPrice = parseFloat(form.price);
const price = priceMode === "total" ? rawPrice / qty : rawPrice;
const holding = await createHolding({ const holding = await createHolding({
account_id: form.account_id, account_id: form.account_id,
asset_id: selected.id, asset_id: selected.id,
quantity: qty, quantity: 0,
avg_cost_basis: price, avg_cost_basis: 0,
currency: form.currency, currency: form.currency,
}); });
await addInvestmentTransaction({ await addInvestmentTransaction({
@ -76,7 +78,8 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
}); });
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: any) {
setError(e?.response?.data?.detail ?? "Failed to add holding"); const detail = e?.response?.data?.detail;
setError(Array.isArray(detail) ? detail.map((d: any) => d.msg).join(", ") : (detail ?? "Failed to add holding"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -159,8 +162,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
placeholder="10" placeholder="10"
/> />
</div> </div>
<div> <div className="col-span-2">
<label className="text-xs font-medium block mb-1">Price paid *</label> <div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium">
{priceMode === "per_share" ? "Price per share *" : "Total price paid *"}
</label>
<div className="flex rounded-md border border-input overflow-hidden text-xs">
<button
type="button"
onClick={() => setPriceMode("per_share")}
className={`px-2 py-0.5 transition-colors ${priceMode === "per_share" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
>
Per share
</button>
<button
type="button"
onClick={() => setPriceMode("total")}
className={`px-2 py-0.5 transition-colors ${priceMode === "total" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
>
Total
</button>
</div>
</div>
<div className="flex gap-1"> <div className="flex gap-1">
<select <select
value={form.currency} value={form.currency}
@ -174,20 +197,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
value={form.price} value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))} onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="150.00" placeholder={priceMode === "per_share" ? "150.00" : "1500.00"}
/> />
</div> </div>
{form.price && form.quantity && parseFloat(form.quantity) > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{priceMode === "per_share"
? `Total: ${form.currency} ${(parseFloat(form.price) * parseFloat(form.quantity)).toFixed(2)}`
: `Per share: ${form.currency} ${(parseFloat(form.price) / parseFloat(form.quantity)).toFixed(4)}`
}
</p>
)}
</div> </div>
<div> </div>
<label className="text-xs font-medium block mb-1">Fees</label> <div>
<input <label className="text-xs font-medium block mb-1">Fees</label>
type="number" min="0" step="any" <input
value={form.fees} type="number" min="0" step="any"
onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))} value={form.fees}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring" onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))}
placeholder="0" className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/> placeholder="0"
</div> />
</div> </div>
{selected && form.currency !== selected.currency && ( {selected && form.currency !== selected.currency && (
@ -213,6 +244,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
Cancel Cancel
</button> </button>
<button <button
type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id} disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors" className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"

View file

@ -0,0 +1,295 @@
import {
PieChart, Pie, Cell, Tooltip, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid, ReferenceLine, LabelList,
} from "recharts";
import { formatCurrency } from "@/utils/currency";
import type { PortfolioSummary, HoldingResponse } from "@/api/investments";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
const TYPE_COLORS: Record<string, string> = {
stock: "#6366f1",
etf: "#22c55e",
crypto: "#f97316",
fund: "#14b8a6",
bond: "#f59e0b",
other: "#94a3b8",
};
function typeColor(type: string) {
return TYPE_COLORS[type.toLowerCase()] ?? TYPE_COLORS.other;
}
function DonutTooltip({ active, payload, currency }: any) {
if (!active || !payload?.length) return null;
const d = payload[0];
return (
<div className="bg-card border border-border rounded-lg px-3 py-2 text-xs shadow-lg">
<p className="font-semibold">{d.name}</p>
<p className="text-muted-foreground">{formatCurrency(d.value, currency)}</p>
<p className="text-muted-foreground">{d.payload.pct?.toFixed(1)}%</p>
</div>
);
}
// ── Donut: allocation by holding ─────────────────────────────────────────────
export function AllocationDonut({ portfolio }: { portfolio: PortfolioSummary }) {
const val = (h: HoldingResponse) => Number(h.current_value || h.cost_basis_total);
const total = portfolio.holdings.reduce((s, h) => s + val(h), 0);
const data = portfolio.holdings
.filter((h) => val(h) > 0)
.map((h, i) => ({
name: h.symbol,
value: val(h),
pct: (val(h) / total) * 100,
fill: COLORS[i % COLORS.length],
}));
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation by Holding</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="80%"
paddingAngle={2}
dataKey="value"
>
{data.map((d) => (
<Cell key={d.name} fill={d.fill} stroke="transparent" />
))}
</Pie>
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
<span>{d.name}</span>
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
}
// ── Donut: allocation by asset type ──────────────────────────────────────────
export function AssetTypeDonut({ portfolio }: { portfolio: PortfolioSummary }) {
const byType: Record<string, number> = {};
for (const h of portfolio.holdings) {
const t = (h.asset_type || "other").toLowerCase();
byType[t] = (byType[t] ?? 0) + Number(h.current_value || h.cost_basis_total);
}
const total = Object.values(byType).reduce((s, v) => s + v, 0);
const data = Object.entries(byType)
.filter(([, v]) => v > 0)
.map(([type, value]) => ({
name: type.charAt(0).toUpperCase() + type.slice(1),
value,
pct: (value / total) * 100,
fill: typeColor(type),
}))
.sort((a, b) => b.value - a.value);
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation by Asset Type</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="80%"
paddingAngle={2}
dataKey="value"
>
{data.map((d) => (
<Cell key={d.name} fill={d.fill} stroke="transparent" />
))}
</Pie>
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
<span>{d.name}</span>
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
}
// ── Bar: cost basis vs current value ─────────────────────────────────────────
function CurrencyYAxis({ value, currency }: { value: number; currency: string }) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
return formatCurrency(value, currency);
}
export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) {
const data = portfolio.holdings
.filter((h) => h.cost_basis_total > 0)
.map((h) => ({
symbol: h.symbol,
cost: h.cost_basis_total,
value: h.current_value ?? h.cost_basis_total,
gain: (h.current_value ?? h.cost_basis_total) - h.cost_basis_total,
}))
.sort((a, b) => b.value - a.value);
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Cost Basis vs Current Value</p>
<ResponsiveContainer width="100%" height={Math.max(200, data.length * 52)}>
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="symbol"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => CurrencyYAxis({ value: v, currency: portfolio.currency })}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip
formatter={(value: number, name: string) => [
formatCurrency(value, portfolio.currency),
name === "cost" ? "Cost basis" : "Current value",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
{data.map((d) => (
<Cell
key={d.symbol}
fill={d.gain >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-muted-foreground opacity-40" />
Cost basis
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--success))" }} />
Current value (gain)
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--destructive))" }} />
Current value (loss)
</div>
</div>
</div>
);
}
// ── Bar: return % per holding ─────────────────────────────────────────────────
export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
const data = portfolio.holdings
.filter((h) => h.unrealised_gain_pct != null)
.map((h) => ({
symbol: h.symbol,
pct: Number(h.unrealised_gain_pct),
gain: h.unrealised_gain ?? 0,
}))
.sort((a, b) => b.pct - a.pct);
if (data.length === 0) return null;
const absMax = Math.max(...data.map((d) => Math.abs(d.pct)), 5);
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Return % by Holding</p>
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 44)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 4, right: 48, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
<XAxis
type="number"
domain={[-absMax, absMax]}
tickFormatter={(v) => `${v.toFixed(0)}%`}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
/>
<YAxis
type="category"
dataKey="symbol"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
width={48}
/>
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
<Tooltip
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
<LabelList
dataKey="pct"
position="right"
formatter={(v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`}
style={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
/>
{data.map((d) => (
<Cell
key={d.symbol}
fill={d.pct >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
fillOpacity={0.85}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -7,12 +7,9 @@ import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react"; import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal"; import AddHoldingModal from "./AddHoldingModal";
import { AllocationDonut, AssetTypeDonut, CostVsValueChart, ReturnChart } from "./PortfolioCharts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: { function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
holding: HoldingResponse; holding: HoldingResponse;
@ -156,14 +153,6 @@ export default function PortfolioPage() {
}, },
}); });
const treemapData = portfolio?.holdings
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
.map((h, i) => ({
name: h.symbol,
size: Number(h.current_value ?? h.cost_basis_total),
fill: COLORS[i % COLORS.length],
})) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -201,34 +190,21 @@ export default function PortfolioPage() {
</div> </div>
)} )}
{/* Allocation bar */} {/* Charts */}
{treemapData.length > 1 && ( {portfolio && portfolio.holdings.length > 0 && (
<div className="bg-card border border-border rounded-xl p-4"> <>
<p className="text-sm font-medium mb-3">Allocation</p> {/* Two donuts side by side */}
<div className="flex gap-1 flex-wrap"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(() => { <AllocationDonut portfolio={portfolio} />
const total = treemapData.reduce((s, d) => s + d.size, 0); <AssetTypeDonut portfolio={portfolio} />
return treemapData.map((d, i) => (
<div
key={d.name}
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
>
{d.size / total > 0.06 ? d.name : ""}
</div>
));
})()}
</div> </div>
<div className="flex flex-wrap gap-3 mt-3">
{treemapData.map((d, i) => ( {/* Cost vs value */}
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground"> <CostVsValueChart portfolio={portfolio} />
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
{d.name} {formatCurrency(d.size, "GBP")} {/* Return % per holding */}
</div> <ReturnChart portfolio={portfolio} />
))} </>
</div>
</div>
)} )}
{/* Holdings table */} {/* Holdings table */}