@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.
@@ -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
+ }
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
+ }