0.2.40: bitcoin-denominated line on the LP capital chart

The capital-over-time chart gains a dashed orange series on its own
right-hand ₿ axis: total value (ending balance + distributions) divided
by the BTC price at each statement date — the time-series of the
"In bitcoin terms" box. The history table gains a matching In-bitcoin
column. Frontend-only; prices were already stamped per statement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-14 09:10:14 +02:00
co-authored by Claude Fable 5
parent eac3262f29
commit 5d4e87e69b
7 changed files with 93 additions and 6 deletions
+60 -1
View File
@@ -6,6 +6,7 @@ export interface CapitalPoint {
value: number; // ending balance, cents
paidIn: number; // cumulative contributions, cents
distributions: number; // cumulative distributions, cents
btcValue?: number | null; // total value (NAV + distributions) in BTC at that date's price
}
const SERIES = [
@@ -14,6 +15,13 @@ const SERIES = [
{ key: "distributions" as const, label: "Distributions", color: "#16a34a" },
];
const BTC_COLOR = "#f7931a";
const formatBtcAxis = (v: number) => {
const dp = v >= 10 ? 1 : v >= 1 ? 2 : 3;
return `${v.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })}`;
};
// A compact, dependency-free multi-line SVG chart of an investor's capital over time.
export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
const data = useMemo(
@@ -28,10 +36,18 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
const hasDistributions = data.some((d) => d.distributions > 0);
const series = hasDistributions ? SERIES : SERIES.filter((s) => s.key !== "distributions");
// The bitcoin-denominated value plots on its own right-hand axis — BTC and dollar
// magnitudes are incomparable. Prices may be missing for the earliest statements
// (uploaded CSV starts later), so the ₿ line covers only the dated points it has.
const btcPoints = data
.map((d, i) => ({ i, btc: d.btcValue }))
.filter((p): p is { i: number; btc: number } => p.btc != null && p.btc > 0);
const showBtc = btcPoints.length >= 2;
const W = 640;
const H = 240;
const padL = 64;
const padR = 16;
const padR = showBtc ? 60 : 16;
const padT = 16;
const padB = 32;
const innerW = W - padL - padR;
@@ -41,6 +57,9 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
const x = (i: number) => padL + (data.length === 1 ? innerW / 2 : (innerW * i) / (data.length - 1));
const y = (v: number) => padT + innerH - (innerH * v) / maxVal;
const maxBtc = showBtc ? Math.max(...btcPoints.map((p) => p.btc)) : 1;
const yBtc = (v: number) => padT + innerH - (innerH * v) / maxBtc;
// 4 horizontal gridlines with dollar labels.
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(maxVal * f));
@@ -56,6 +75,21 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
</g>
))}
{showBtc &&
[0.25, 0.5, 0.75, 1].map((f) => (
<text
key={`btc-${f}`}
x={W - padR + 8}
y={yBtc(maxBtc * f) + 4}
textAnchor="start"
fontSize={11}
fill={BTC_COLOR}
opacity={0.75}
>
{formatBtcAxis(maxBtc * f)}
</text>
))}
{series.map((s) => (
<polyline
key={s.key}
@@ -74,6 +108,22 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
)),
)}
{showBtc && (
<polyline
fill="none"
stroke={BTC_COLOR}
strokeWidth={2}
strokeDasharray="5 3"
points={btcPoints.map((p) => `${x(p.i)},${yBtc(p.btc)}`).join(" ")}
/>
)}
{showBtc &&
btcPoints.map((p) => (
<circle key={`btc-${p.i}`} cx={x(p.i)} cy={yBtc(p.btc)} r={2.5} fill={BTC_COLOR}>
<title>{`${formatQuarter(data[p.i].date)} · In bitcoin: ${formatBtcAxis(p.btc)}`}</title>
</circle>
))}
{data.map((d, i) => (
<text key={`xl-${i}`} x={x(i)} y={H - 10} textAnchor="middle" fontSize={11} fill="#9ca3af">
{formatQuarter(d.date)}
@@ -88,6 +138,15 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
{s.label}
</span>
))}
{showBtc && (
<span className="flex items-center gap-1.5 text-xs text-gray-500">
<span
className="inline-block w-3 border-t-2 border-dashed"
style={{ borderColor: BTC_COLOR }}
/>
In bitcoin (right axis)
</span>
)}
</div>
</div>
);
@@ -471,12 +471,18 @@ function HistorySection({ history }: { history: CapitalAccount[] }) {
const [showChart, setShowChart] = useState(false);
if (history.length < 2) return null;
// Total value (NAV + distributions received) in BTC at each statement date's price —
// the time-series of the "In bitcoin terms" box. Null until prices cover that date.
const chartPoints: CapitalPoint[] = history.map((a) => ({
date: a.as_of_date,
value: a.ending_balance_cents,
paidIn: a.contributions_cents,
distributions: a.distributions_cents,
btcValue: a.btc_price_cents
? (a.ending_balance_cents + a.distributions_cents) / a.btc_price_cents
: null,
}));
const hasBtc = history.some((a) => a.btc_price_cents);
return (
<div className="mt-5">
@@ -502,6 +508,7 @@ function HistorySection({ history }: { history: CapitalAccount[] }) {
<th className="py-1 font-medium text-right">Paid-in</th>
<th className="py-1 font-medium text-right">Distributions</th>
<th className="py-1 font-medium text-right">Ending balance</th>
{hasBtc && <th className="py-1 font-medium text-right">In bitcoin</th>}
</tr>
</thead>
<tbody>
@@ -511,6 +518,13 @@ function HistorySection({ history }: { history: CapitalAccount[] }) {
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.contributions_cents)}</td>
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.distributions_cents)}</td>
<td className="py-1 text-right text-gray-900">{formatMoneyExact(a.ending_balance_cents)}</td>
{hasBtc && (
<td className="py-1 text-right text-gray-600">
{a.btc_price_cents
? formatBtc(a.ending_balance_cents + a.distributions_cents, a.btc_price_cents)
: "—"}
</td>
)}
</tr>
))}
</tbody>
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served.
export const APP_VERSION = "0.2.39";
export const APP_VERSION = "0.2.40";