@timecell/web 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components.json +23 -0
- package/index.html +13 -0
- package/package.json +32 -0
- package/public/logo.png +0 -0
- package/public/timecell.svg +4 -0
- package/src/App.tsx +117 -0
- package/src/components/ConvictionLadder.tsx +113 -0
- package/src/components/CrashCard.tsx +180 -0
- package/src/components/CrashChart.tsx +139 -0
- package/src/components/CrashGrid.tsx +12 -0
- package/src/components/InfoPanel.tsx +44 -0
- package/src/components/PortfolioForm.tsx +134 -0
- package/src/components/PositionSizing.tsx +382 -0
- package/src/components/SurvivalHero.tsx +90 -0
- package/src/components/SurvivalSummary.tsx +47 -0
- package/src/components/TemperatureGauge.tsx +283 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/slider.tsx +26 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/hooks/usePortfolio.ts +110 -0
- package/src/index.css +115 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +20 -0
package/components.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "tailwind.config.js",
|
|
8
|
+
"css": "src/index.css",
|
|
9
|
+
"baseColor": "slate",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"aliases": {
|
|
16
|
+
"components": "@/components",
|
|
17
|
+
"utils": "@/lib/utils",
|
|
18
|
+
"ui": "@/components/ui",
|
|
19
|
+
"lib": "@/lib",
|
|
20
|
+
"hooks": "@/hooks"
|
|
21
|
+
},
|
|
22
|
+
"registries": {}
|
|
23
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/timecell.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>TimeCell</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timecell/web",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "TimeCell web application",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite --port 3738",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@radix-ui/react-separator": "^1.1.8",
|
|
13
|
+
"@radix-ui/react-slider": "^1.3.6",
|
|
14
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
15
|
+
"class-variance-authority": "^0.7.1",
|
|
16
|
+
"clsx": "^2.1.1",
|
|
17
|
+
"lucide-react": "^0.575.0",
|
|
18
|
+
"react": "^19",
|
|
19
|
+
"react-dom": "^19",
|
|
20
|
+
"recharts": "^3.7.0",
|
|
21
|
+
"tailwind-merge": "^3.5.0",
|
|
22
|
+
"tailwindcss-animate": "^1.0.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
26
|
+
"@types/react": "^19",
|
|
27
|
+
"@types/react-dom": "^19",
|
|
28
|
+
"@vitejs/plugin-react": "latest",
|
|
29
|
+
"tailwindcss": "^4",
|
|
30
|
+
"vite": "^6"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/public/logo.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
|
2
|
+
<rect width="32" height="32" rx="6" fill="#f97316"/>
|
|
3
|
+
<text x="16" y="23" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="700" font-size="22" fill="white">T</text>
|
|
4
|
+
</svg>
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { usePortfolio } from "./hooks/usePortfolio";
|
|
3
|
+
import { SurvivalHero } from "./components/SurvivalHero";
|
|
4
|
+
import { PortfolioForm } from "./components/PortfolioForm";
|
|
5
|
+
import { CrashChart } from "./components/CrashChart";
|
|
6
|
+
import { CrashGrid } from "./components/CrashGrid";
|
|
7
|
+
import { ConvictionLadder } from "./components/ConvictionLadder";
|
|
8
|
+
import { InfoPanel } from "./components/InfoPanel";
|
|
9
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
10
|
+
|
|
11
|
+
export default function App() {
|
|
12
|
+
const { portfolio, currencySymbol, result, loading, error, savedAt, loadPortfolio, updatePortfolio } = usePortfolio();
|
|
13
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
loadPortfolio();
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<TooltipProvider delayDuration={300}>
|
|
21
|
+
<div className="min-h-screen bg-gradient-to-br from-slate-950 to-slate-900 text-white">
|
|
22
|
+
{/* Header */}
|
|
23
|
+
<header className="border-b border-slate-800 px-4 sm:px-6 py-4">
|
|
24
|
+
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
25
|
+
<div className="flex items-center gap-3">
|
|
26
|
+
<img src="/logo.png" alt="TimeCell" className="h-8 brightness-110" />
|
|
27
|
+
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
|
|
28
|
+
v0.1
|
|
29
|
+
</span>
|
|
30
|
+
</div>
|
|
31
|
+
<span className="text-xs sm:text-sm text-slate-500">Crash Survival Calculator</span>
|
|
32
|
+
</div>
|
|
33
|
+
</header>
|
|
34
|
+
|
|
35
|
+
{/* Main content */}
|
|
36
|
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-6">
|
|
37
|
+
{/* Error banner */}
|
|
38
|
+
{error && (
|
|
39
|
+
<div className="rounded-lg border border-red-500/50 bg-red-900/20 px-4 py-3 text-sm text-red-400">
|
|
40
|
+
{error}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{/* ZONE 1: Hero — Big survival score + ruin test */}
|
|
45
|
+
{result && <SurvivalHero result={result} />}
|
|
46
|
+
|
|
47
|
+
{/* Loading skeleton for hero */}
|
|
48
|
+
{!result && loading && (
|
|
49
|
+
<div className="rounded-2xl border border-slate-700 bg-slate-800/30 p-8 animate-pulse">
|
|
50
|
+
<div className="flex items-center gap-10">
|
|
51
|
+
<div>
|
|
52
|
+
<div className="h-4 w-32 bg-slate-700 rounded mb-4" />
|
|
53
|
+
<div className="h-20 w-36 bg-slate-700 rounded" />
|
|
54
|
+
</div>
|
|
55
|
+
<div className="hidden sm:block w-px h-24 bg-slate-700" />
|
|
56
|
+
<div className="flex-1 space-y-4">
|
|
57
|
+
<div className="h-12 w-48 bg-slate-700 rounded" />
|
|
58
|
+
<div className="flex gap-6">
|
|
59
|
+
<div className="h-10 w-28 bg-slate-700 rounded" />
|
|
60
|
+
<div className="h-10 w-28 bg-slate-700 rounded" />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{/* ZONE 2: Interactive — Sliders + Chart side by side */}
|
|
68
|
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
69
|
+
<div className="lg:col-span-2">
|
|
70
|
+
<PortfolioForm portfolio={portfolio} onUpdate={updatePortfolio} savedAt={savedAt} currencySymbol={currencySymbol} />
|
|
71
|
+
</div>
|
|
72
|
+
<div className="lg:col-span-3">
|
|
73
|
+
{result && !loading && <CrashChart result={result} currencySymbol={currencySymbol} />}
|
|
74
|
+
{loading && (
|
|
75
|
+
<div className="rounded-xl border border-slate-700 bg-slate-800/30 p-6 animate-pulse h-full min-h-[300px]">
|
|
76
|
+
<div className="h-5 w-48 bg-slate-700 rounded mb-6" />
|
|
77
|
+
<div className="h-full bg-slate-700/30 rounded" />
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* ZONE 3: Crash details — collapsed by default */}
|
|
84
|
+
{result && !loading && (
|
|
85
|
+
<div>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setDetailsOpen((o) => !o)}
|
|
89
|
+
className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-200 transition-colors mb-4"
|
|
90
|
+
>
|
|
91
|
+
<span className={`inline-block w-3 h-3 border-r-2 border-b-2 border-current transform transition-transform duration-200 ${
|
|
92
|
+
detailsOpen ? "-rotate-[135deg] translate-y-0.5" : "rotate-45 -translate-y-0.5"
|
|
93
|
+
}`} />
|
|
94
|
+
<span>Crash Scenario Details</span>
|
|
95
|
+
<span className="text-xs text-slate-600">({result.scenarios.length} scenarios)</span>
|
|
96
|
+
</button>
|
|
97
|
+
{detailsOpen && <CrashGrid result={result} currencySymbol={currencySymbol} />}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* ZONE 4: Framework — below fold */}
|
|
102
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
103
|
+
<ConvictionLadder btcPercentage={portfolio.btcPercentage} />
|
|
104
|
+
<InfoPanel />
|
|
105
|
+
</div>
|
|
106
|
+
</main>
|
|
107
|
+
|
|
108
|
+
{/* Footer */}
|
|
109
|
+
<footer className="border-t border-slate-800 px-4 sm:px-6 py-4 mt-8 sm:mt-12">
|
|
110
|
+
<div className="max-w-7xl mx-auto text-center text-xs text-slate-600">
|
|
111
|
+
TimeCell — Local-first. Your data stays on your machine.
|
|
112
|
+
</div>
|
|
113
|
+
</footer>
|
|
114
|
+
</div>
|
|
115
|
+
</TooltipProvider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
2
|
+
|
|
3
|
+
const RUNGS = [
|
|
4
|
+
{ level: 6, name: "Single-Asset Core", range: "50%+", min: 50, max: 100, desc: "Portfolio defined by Bitcoin" },
|
|
5
|
+
{ level: 5, name: "Owner-Class", range: "25-50%", min: 25, max: 50, desc: "Major asset, requires discipline" },
|
|
6
|
+
{ level: 4, name: "High Conviction", range: "10-25%", min: 10, max: 25, desc: "Core holding, conviction-based" },
|
|
7
|
+
{ level: 3, name: "Diversifier", range: "5-10%", min: 5, max: 10, desc: "Meaningful allocation, portfolio diversifier" },
|
|
8
|
+
{ level: 2, name: "Experimenter", range: "1-3%", min: 1, max: 3, desc: "Small position to learn" },
|
|
9
|
+
{ level: 1, name: "Observer", range: "0%", min: 0, max: 0, desc: "Watching from the sidelines" },
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
function getActiveRung(btcPercentage: number): number {
|
|
13
|
+
if (btcPercentage >= 50) return 6;
|
|
14
|
+
if (btcPercentage >= 25) return 5;
|
|
15
|
+
if (btcPercentage >= 10) return 4;
|
|
16
|
+
if (btcPercentage >= 5) return 3;
|
|
17
|
+
if (btcPercentage >= 1) return 2;
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ConvictionLadder({ btcPercentage }: { btcPercentage: number }) {
|
|
22
|
+
const activeLevel = getActiveRung(btcPercentage);
|
|
23
|
+
const showGates = btcPercentage >= 25;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
27
|
+
<CardContent className="p-4 sm:p-6">
|
|
28
|
+
<h3 className="text-base sm:text-lg font-semibold text-white mb-1">
|
|
29
|
+
Conviction Ladder
|
|
30
|
+
</h3>
|
|
31
|
+
<p className="text-xs text-slate-400 mb-4">
|
|
32
|
+
Your {btcPercentage}% BTC allocation
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<div className="space-y-1.5">
|
|
36
|
+
{RUNGS.map((rung) => {
|
|
37
|
+
const isActive = rung.level === activeLevel;
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
key={rung.level}
|
|
41
|
+
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${
|
|
42
|
+
isActive
|
|
43
|
+
? "bg-orange-500/15 border border-orange-500/40"
|
|
44
|
+
: "bg-slate-800/40 border border-transparent"
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
{/* Rung number */}
|
|
48
|
+
<div
|
|
49
|
+
className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
|
50
|
+
isActive
|
|
51
|
+
? "bg-orange-500 text-white"
|
|
52
|
+
: "bg-slate-700 text-slate-400"
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
{rung.level}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{/* Rung info */}
|
|
59
|
+
<div className="flex-1 min-w-0">
|
|
60
|
+
<div className="flex items-center gap-2">
|
|
61
|
+
<span
|
|
62
|
+
className={`text-sm font-medium ${
|
|
63
|
+
isActive ? "text-orange-400" : "text-slate-300"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{rung.name}
|
|
67
|
+
</span>
|
|
68
|
+
<span
|
|
69
|
+
className={`text-xs ${
|
|
70
|
+
isActive ? "text-orange-400/70" : "text-slate-500"
|
|
71
|
+
}`}
|
|
72
|
+
>
|
|
73
|
+
{rung.range}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
<p
|
|
77
|
+
className={`text-xs truncate ${
|
|
78
|
+
isActive ? "text-orange-300/60" : "text-slate-500"
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
{rung.desc}
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Active indicator */}
|
|
86
|
+
{isActive && (
|
|
87
|
+
<div className="flex-shrink-0 w-2 h-2 rounded-full bg-orange-400 animate-pulse" />
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Gates warning for 25%+ */}
|
|
95
|
+
{showGates && (
|
|
96
|
+
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-900/15 px-3 py-2.5">
|
|
97
|
+
<div className="flex items-start gap-2">
|
|
98
|
+
<span className="text-amber-400 text-sm flex-shrink-0 mt-0.5">!</span>
|
|
99
|
+
<div>
|
|
100
|
+
<p className="text-xs font-medium text-amber-400 mb-1">
|
|
101
|
+
Gates for 25%+ allocation
|
|
102
|
+
</p>
|
|
103
|
+
<p className="text-xs text-amber-300/60 leading-relaxed">
|
|
104
|
+
Multi-cycle experience, 2yr expenses outside BTC, no forced-sale liabilities, written de-risk triggers
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { CrashScenario } from "../hooks/usePortfolio";
|
|
2
|
+
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
import { Separator } from "@/components/ui/separator";
|
|
5
|
+
import {
|
|
6
|
+
Tooltip,
|
|
7
|
+
TooltipTrigger,
|
|
8
|
+
TooltipContent,
|
|
9
|
+
} from "@/components/ui/tooltip";
|
|
10
|
+
|
|
11
|
+
function formatCurrency(value: number, symbol = "$"): string {
|
|
12
|
+
if (value >= 1_000_000) return `${symbol}${(value / 1_000_000).toFixed(1)}M`;
|
|
13
|
+
if (value >= 1_000) return `${symbol}${(value / 1_000).toFixed(0)}K`;
|
|
14
|
+
return `${symbol}${value.toFixed(0)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatMonths(months: number): string {
|
|
18
|
+
if (months === Infinity) return "\u221e";
|
|
19
|
+
if (months > 120) return "120mo+";
|
|
20
|
+
return `${months.toFixed(0)}mo`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get visual severity style based on drawdown percentage.
|
|
25
|
+
* Even if the survival status is "safe", deeper drawdowns get warmer colors
|
|
26
|
+
* to communicate relative risk at a glance.
|
|
27
|
+
*/
|
|
28
|
+
function getSeverityStyle(scenario: CrashScenario) {
|
|
29
|
+
// If not safe, use status-based styling
|
|
30
|
+
if (scenario.survivalStatus === "critical") {
|
|
31
|
+
return {
|
|
32
|
+
bg: "bg-red-900/30",
|
|
33
|
+
border: "border-red-500/50",
|
|
34
|
+
badgeVariant: "destructive" as const,
|
|
35
|
+
badgeClass: "bg-red-500 text-black hover:bg-red-500",
|
|
36
|
+
text: "text-red-400",
|
|
37
|
+
label: "CRITICAL",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (scenario.survivalStatus === "warning") {
|
|
41
|
+
return {
|
|
42
|
+
bg: "bg-amber-900/30",
|
|
43
|
+
border: "border-amber-500/50",
|
|
44
|
+
badgeVariant: "secondary" as const,
|
|
45
|
+
badgeClass: "bg-amber-500 text-black hover:bg-amber-500",
|
|
46
|
+
text: "text-amber-400",
|
|
47
|
+
label: "WARNING",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Safe status — apply gradient severity based on drawdown depth
|
|
52
|
+
if (scenario.drawdownPct <= 30) {
|
|
53
|
+
return {
|
|
54
|
+
bg: "bg-emerald-900/30",
|
|
55
|
+
border: "border-emerald-500/50",
|
|
56
|
+
badgeVariant: "outline" as const,
|
|
57
|
+
badgeClass: "bg-emerald-500 text-black border-emerald-500 hover:bg-emerald-500",
|
|
58
|
+
text: "text-emerald-400",
|
|
59
|
+
label: "SAFE",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (scenario.drawdownPct <= 50) {
|
|
63
|
+
return {
|
|
64
|
+
bg: "bg-emerald-900/20",
|
|
65
|
+
border: "border-emerald-500/30",
|
|
66
|
+
badgeVariant: "outline" as const,
|
|
67
|
+
badgeClass: "bg-emerald-600 text-black border-emerald-600 hover:bg-emerald-600",
|
|
68
|
+
text: "text-emerald-400",
|
|
69
|
+
label: "SAFE",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (scenario.drawdownPct <= 70) {
|
|
73
|
+
return {
|
|
74
|
+
bg: "bg-amber-900/15",
|
|
75
|
+
border: "border-amber-500/30",
|
|
76
|
+
badgeVariant: "secondary" as const,
|
|
77
|
+
badgeClass: "bg-amber-500 text-black hover:bg-amber-500",
|
|
78
|
+
text: "text-amber-400",
|
|
79
|
+
label: "SAFE",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// 80%+
|
|
83
|
+
return {
|
|
84
|
+
bg: "bg-orange-900/20",
|
|
85
|
+
border: "border-orange-500/30",
|
|
86
|
+
badgeVariant: "secondary" as const,
|
|
87
|
+
badgeClass: "bg-orange-500 text-black hover:bg-orange-500",
|
|
88
|
+
text: "text-orange-400",
|
|
89
|
+
label: "SAFE",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CrashCard({ scenario, currencySymbol = "$" }: { scenario: CrashScenario; currencySymbol?: string }) {
|
|
94
|
+
const style = getSeverityStyle(scenario);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Card
|
|
98
|
+
className={`${style.border} ${style.bg} transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:shadow-black/20 shadow-none`}
|
|
99
|
+
>
|
|
100
|
+
<CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0 p-3 sm:p-5 pb-0 sm:pb-0">
|
|
101
|
+
<span className="text-xl sm:text-2xl font-bold text-white">-{scenario.drawdownPct}%</span>
|
|
102
|
+
<Badge
|
|
103
|
+
variant={style.badgeVariant}
|
|
104
|
+
className={`${style.badgeClass} rounded-full text-xs font-bold px-2.5 py-1 w-fit`}
|
|
105
|
+
>
|
|
106
|
+
{style.label}
|
|
107
|
+
</Badge>
|
|
108
|
+
</CardHeader>
|
|
109
|
+
|
|
110
|
+
<CardContent className="p-3 sm:p-5 pt-3 sm:pt-4">
|
|
111
|
+
<div className="space-y-2 sm:space-y-3">
|
|
112
|
+
<div className="flex justify-between gap-2">
|
|
113
|
+
<span className="text-slate-300 text-xs sm:text-sm flex-shrink-0">BTC Price</span>
|
|
114
|
+
<span className="text-white font-mono text-xs sm:text-sm text-right">
|
|
115
|
+
{formatCurrency(scenario.btcPriceAtCrash, currencySymbol)}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex justify-between gap-2">
|
|
119
|
+
<span className="text-slate-300 text-xs sm:text-sm flex-shrink-0">Portfolio Value</span>
|
|
120
|
+
<span className={`font-mono text-xs sm:text-sm ${style.text} text-right`}>
|
|
121
|
+
{formatCurrency(scenario.portfolioValueAfterCrash, currencySymbol)}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
{scenario.hedgePayoff > 0 && (
|
|
125
|
+
<div className="flex justify-between gap-2">
|
|
126
|
+
<Tooltip>
|
|
127
|
+
<TooltipTrigger asChild>
|
|
128
|
+
<span className="text-slate-300 text-xs sm:text-sm flex-shrink-0 cursor-help underline decoration-dotted underline-offset-4 decoration-slate-600">
|
|
129
|
+
Hedge Payoff
|
|
130
|
+
</span>
|
|
131
|
+
</TooltipTrigger>
|
|
132
|
+
<TooltipContent>
|
|
133
|
+
<p>Value of put options at this crash level</p>
|
|
134
|
+
</TooltipContent>
|
|
135
|
+
</Tooltip>
|
|
136
|
+
<span className="text-emerald-400 font-mono text-xs sm:text-sm text-right">
|
|
137
|
+
+{formatCurrency(scenario.hedgePayoff, currencySymbol)}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
<div className="flex justify-between gap-2">
|
|
142
|
+
<Tooltip>
|
|
143
|
+
<TooltipTrigger asChild>
|
|
144
|
+
<span className="text-slate-300 text-xs sm:text-sm flex-shrink-0 cursor-help underline decoration-dotted underline-offset-4 decoration-slate-600">
|
|
145
|
+
Net Position
|
|
146
|
+
</span>
|
|
147
|
+
</TooltipTrigger>
|
|
148
|
+
<TooltipContent>
|
|
149
|
+
<p>Portfolio value after crash + hedge payoff + liquid reserve</p>
|
|
150
|
+
</TooltipContent>
|
|
151
|
+
</Tooltip>
|
|
152
|
+
<span className="text-white font-mono text-xs sm:text-sm font-bold text-right">
|
|
153
|
+
{formatCurrency(scenario.netPosition, currencySymbol)}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
<Separator className="bg-slate-700" />
|
|
157
|
+
<div className="flex justify-between gap-2">
|
|
158
|
+
<Tooltip>
|
|
159
|
+
<TooltipTrigger asChild>
|
|
160
|
+
<span className="text-slate-300 text-xs sm:text-sm flex-shrink-0 cursor-help underline decoration-dotted underline-offset-4 decoration-slate-600">
|
|
161
|
+
Runway
|
|
162
|
+
</span>
|
|
163
|
+
</TooltipTrigger>
|
|
164
|
+
<TooltipContent>
|
|
165
|
+
<p>
|
|
166
|
+
{isFinite(scenario.runwayMonths) && scenario.runwayMonths > 0
|
|
167
|
+
? `Months of ${formatCurrency(Math.round(scenario.netPosition / scenario.runwayMonths), currencySymbol)}/mo burn covered by remaining assets`
|
|
168
|
+
: "Months of expenses covered by remaining assets"}
|
|
169
|
+
</p>
|
|
170
|
+
</TooltipContent>
|
|
171
|
+
</Tooltip>
|
|
172
|
+
<span className={`font-mono text-xs sm:text-sm font-bold ${style.text} text-right`}>
|
|
173
|
+
{formatMonths(scenario.runwayMonths)}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</CardContent>
|
|
178
|
+
</Card>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComposedChart,
|
|
3
|
+
Bar,
|
|
4
|
+
Line,
|
|
5
|
+
XAxis,
|
|
6
|
+
YAxis,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
Cell,
|
|
11
|
+
} from "recharts";
|
|
12
|
+
import type { SurvivalResult, CrashScenario } from "../hooks/usePortfolio";
|
|
13
|
+
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
14
|
+
|
|
15
|
+
function formatCurrency(value: number, symbol = "$"): string {
|
|
16
|
+
if (value >= 1_000_000) return `${symbol}${(value / 1_000_000).toFixed(1)}M`;
|
|
17
|
+
if (value >= 1_000) return `${symbol}${(value / 1_000).toFixed(0)}K`;
|
|
18
|
+
return `${symbol}${value.toFixed(0)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getBarColor(scenario: CrashScenario): string {
|
|
22
|
+
if (scenario.survivalStatus === "critical") return "#ef4444"; // red-500
|
|
23
|
+
if (scenario.survivalStatus === "warning") return "#f59e0b"; // amber-500
|
|
24
|
+
if (scenario.drawdownPct <= 30) return "#10b981"; // emerald-500
|
|
25
|
+
if (scenario.drawdownPct <= 50) return "#059669"; // emerald-600
|
|
26
|
+
if (scenario.drawdownPct <= 70) return "#f59e0b"; // amber-500
|
|
27
|
+
return "#f97316"; // orange-500
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ChartDataPoint {
|
|
31
|
+
label: string;
|
|
32
|
+
portfolioValue: number;
|
|
33
|
+
runway: number;
|
|
34
|
+
scenario: CrashScenario;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CrashChart({ result, currencySymbol = "$" }: { result: SurvivalResult; currencySymbol?: string }) {
|
|
38
|
+
const data: ChartDataPoint[] = result.scenarios.map((s) => ({
|
|
39
|
+
label: `-${s.drawdownPct}%`,
|
|
40
|
+
portfolioValue: s.portfolioValueAfterCrash,
|
|
41
|
+
runway: s.runwayMonths === Infinity ? 240 : Math.min(s.runwayMonths, 240),
|
|
42
|
+
scenario: s,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
47
|
+
<CardHeader className="p-4 sm:p-6 pb-0 sm:pb-0">
|
|
48
|
+
<CardTitle className="text-base sm:text-lg text-white">
|
|
49
|
+
Portfolio Value & Runway by Crash Scenario
|
|
50
|
+
</CardTitle>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardContent className="p-4 sm:p-6 pt-4 sm:pt-4">
|
|
53
|
+
<div className="h-64">
|
|
54
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
55
|
+
<ComposedChart
|
|
56
|
+
data={data}
|
|
57
|
+
margin={{ top: 8, right: 16, left: 8, bottom: 0 }}
|
|
58
|
+
>
|
|
59
|
+
<CartesianGrid
|
|
60
|
+
strokeDasharray="3 3"
|
|
61
|
+
stroke="#334155"
|
|
62
|
+
vertical={false}
|
|
63
|
+
/>
|
|
64
|
+
<XAxis
|
|
65
|
+
dataKey="label"
|
|
66
|
+
tick={{ fill: "#94a3b8", fontSize: 13 }}
|
|
67
|
+
axisLine={{ stroke: "#475569" }}
|
|
68
|
+
tickLine={false}
|
|
69
|
+
/>
|
|
70
|
+
<YAxis
|
|
71
|
+
yAxisId="left"
|
|
72
|
+
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
|
73
|
+
axisLine={false}
|
|
74
|
+
tickLine={false}
|
|
75
|
+
tickFormatter={(v: number) => formatCurrency(v, currencySymbol)}
|
|
76
|
+
width={64}
|
|
77
|
+
/>
|
|
78
|
+
<YAxis
|
|
79
|
+
yAxisId="right"
|
|
80
|
+
orientation="right"
|
|
81
|
+
tick={{ fill: "#94a3b8", fontSize: 12 }}
|
|
82
|
+
axisLine={false}
|
|
83
|
+
tickLine={false}
|
|
84
|
+
tickFormatter={(v: number) =>
|
|
85
|
+
v >= 240 ? "∞" : `${v}mo`
|
|
86
|
+
}
|
|
87
|
+
width={48}
|
|
88
|
+
/>
|
|
89
|
+
<Tooltip
|
|
90
|
+
contentStyle={{
|
|
91
|
+
backgroundColor: "#1e293b",
|
|
92
|
+
border: "1px solid #475569",
|
|
93
|
+
borderRadius: "8px",
|
|
94
|
+
color: "#e2e8f0",
|
|
95
|
+
fontSize: "13px",
|
|
96
|
+
}}
|
|
97
|
+
formatter={(value: number, name: string) => {
|
|
98
|
+
if (name === "portfolioValue")
|
|
99
|
+
return [formatCurrency(value, currencySymbol), "Portfolio Value"];
|
|
100
|
+
if (name === "runway") {
|
|
101
|
+
const display =
|
|
102
|
+
value >= 240
|
|
103
|
+
? "∞"
|
|
104
|
+
: `${Math.round(value)} months`;
|
|
105
|
+
return [display, "Runway"];
|
|
106
|
+
}
|
|
107
|
+
return [value, name];
|
|
108
|
+
}}
|
|
109
|
+
labelStyle={{ color: "#94a3b8" }}
|
|
110
|
+
/>
|
|
111
|
+
<Bar
|
|
112
|
+
yAxisId="left"
|
|
113
|
+
dataKey="portfolioValue"
|
|
114
|
+
radius={[4, 4, 0, 0]}
|
|
115
|
+
maxBarSize={56}
|
|
116
|
+
>
|
|
117
|
+
{data.map((entry, index) => (
|
|
118
|
+
<Cell
|
|
119
|
+
key={index}
|
|
120
|
+
fill={getBarColor(entry.scenario)}
|
|
121
|
+
fillOpacity={0.85}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</Bar>
|
|
125
|
+
<Line
|
|
126
|
+
yAxisId="right"
|
|
127
|
+
type="monotone"
|
|
128
|
+
dataKey="runway"
|
|
129
|
+
stroke="#fb923c"
|
|
130
|
+
strokeWidth={2}
|
|
131
|
+
dot={{ fill: "#fb923c", r: 4, strokeWidth: 0 }}
|
|
132
|
+
/>
|
|
133
|
+
</ComposedChart>
|
|
134
|
+
</ResponsiveContainer>
|
|
135
|
+
</div>
|
|
136
|
+
</CardContent>
|
|
137
|
+
</Card>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SurvivalResult } from "../hooks/usePortfolio";
|
|
2
|
+
import { CrashCard } from "./CrashCard";
|
|
3
|
+
|
|
4
|
+
export function CrashGrid({ result, currencySymbol = "$" }: { result: SurvivalResult; currencySymbol?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
7
|
+
{result.scenarios.map((scenario) => (
|
|
8
|
+
<CrashCard key={scenario.drawdownPct} scenario={scenario} currencySymbol={currencySymbol} />
|
|
9
|
+
))}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|