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
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user