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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ten31portal-startos", "name": "ten31portal-startos",
"version": "0.2.39", "version": "0.2.40",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+3 -2
View File
@@ -1,4 +1,4 @@
export { v_0_2_39 as current } from './v_0_2_39' export { v_0_2_40 as current } from './v_0_2_40'
import { v_0_1_0 } from './v_0_1_0' import { v_0_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0' import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1' import { v_0_2_1 } from './v_0_2_1'
@@ -38,4 +38,5 @@ import { v_0_2_35 } from './v_0_2_35'
import { v_0_2_36 } from './v_0_2_36' import { v_0_2_36 } from './v_0_2_36'
import { v_0_2_37 } from './v_0_2_37' import { v_0_2_37 } from './v_0_2_37'
import { v_0_2_38 } from './v_0_2_38' import { v_0_2_38 } from './v_0_2_38'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38] import { v_0_2_39 } from './v_0_2_39'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39]
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_40 = VersionInfo.of({
version: '0.2.40:0',
releaseNotes: {
en_US:
'The bitcoin-denominated value now plots on the LP capital chart: a dashed orange line on its own right-hand ₿ axis shows total value (capital balance + distributions) in bitcoin at each statement date, alongside the dollar series. The statement history table gains a matching "In bitcoin" column. Appears automatically wherever BTC prices are uploaded; no setup change.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever) // - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached // - /api/* is never cached
// Bump CACHE on each release so old entries are purged. // 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()) self.addEventListener('install', () => self.skipWaiting())
+60 -1
View File
@@ -6,6 +6,7 @@ export interface CapitalPoint {
value: number; // ending balance, cents value: number; // ending balance, cents
paidIn: number; // cumulative contributions, cents paidIn: number; // cumulative contributions, cents
distributions: number; // cumulative distributions, cents distributions: number; // cumulative distributions, cents
btcValue?: number | null; // total value (NAV + distributions) in BTC at that date's price
} }
const SERIES = [ const SERIES = [
@@ -14,6 +15,13 @@ const SERIES = [
{ key: "distributions" as const, label: "Distributions", color: "#16a34a" }, { 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. // A compact, dependency-free multi-line SVG chart of an investor's capital over time.
export default function CapitalChart({ points }: { points: CapitalPoint[] }) { export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
const data = useMemo( const data = useMemo(
@@ -28,10 +36,18 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
const hasDistributions = data.some((d) => d.distributions > 0); const hasDistributions = data.some((d) => d.distributions > 0);
const series = hasDistributions ? SERIES : SERIES.filter((s) => s.key !== "distributions"); 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 W = 640;
const H = 240; const H = 240;
const padL = 64; const padL = 64;
const padR = 16; const padR = showBtc ? 60 : 16;
const padT = 16; const padT = 16;
const padB = 32; const padB = 32;
const innerW = W - padL - padR; 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 x = (i: number) => padL + (data.length === 1 ? innerW / 2 : (innerW * i) / (data.length - 1));
const y = (v: number) => padT + innerH - (innerH * v) / maxVal; 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. // 4 horizontal gridlines with dollar labels.
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(maxVal * f)); 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> </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) => ( {series.map((s) => (
<polyline <polyline
key={s.key} 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) => ( {data.map((d, i) => (
<text key={`xl-${i}`} x={x(i)} y={H - 10} textAnchor="middle" fontSize={11} fill="#9ca3af"> <text key={`xl-${i}`} x={x(i)} y={H - 10} textAnchor="middle" fontSize={11} fill="#9ca3af">
{formatQuarter(d.date)} {formatQuarter(d.date)}
@@ -88,6 +138,15 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
{s.label} {s.label}
</span> </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>
</div> </div>
); );
@@ -471,12 +471,18 @@ function HistorySection({ history }: { history: CapitalAccount[] }) {
const [showChart, setShowChart] = useState(false); const [showChart, setShowChart] = useState(false);
if (history.length < 2) return null; 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) => ({ const chartPoints: CapitalPoint[] = history.map((a) => ({
date: a.as_of_date, date: a.as_of_date,
value: a.ending_balance_cents, value: a.ending_balance_cents,
paidIn: a.contributions_cents, paidIn: a.contributions_cents,
distributions: a.distributions_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 ( return (
<div className="mt-5"> <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">Paid-in</th>
<th className="py-1 font-medium text-right">Distributions</th> <th className="py-1 font-medium text-right">Distributions</th>
<th className="py-1 font-medium text-right">Ending balance</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> </tr>
</thead> </thead>
<tbody> <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.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-600">{formatMoneyExact(a.distributions_cents)}</td>
<td className="py-1 text-right text-gray-900">{formatMoneyExact(a.ending_balance_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> </tr>
))} ))}
</tbody> </tbody>
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI. // 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, // If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served. // the new frontend isn't actually being served.
export const APP_VERSION = "0.2.39"; export const APP_VERSION = "0.2.40";