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:
co-authored by
Claude Fable 5
parent
eac3262f29
commit
5d4e87e69b
@@ -3,7 +3,7 @@
|
||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||
// - /api/* is never cached
|
||||
// Bump CACHE on each release so old entries are purged.
|
||||
const CACHE = 'ten31-portal-0.2.39'
|
||||
const CACHE = 'ten31-portal-0.2.40'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user