@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,44 @@
1
+ import { Card, CardContent } from "@/components/ui/card";
2
+
3
+ const sections = [
4
+ {
5
+ title: "Crash Model",
6
+ body: "Non-BTC assets drop at ~50% of BTC\u2019s drawdown (correlation model). If BTC drops 80%, non-BTC assets drop ~40%.",
7
+ },
8
+ {
9
+ title: "Survival Threshold",
10
+ body: "18+ months of runway = safe. Less than 6 months = forced seller territory.",
11
+ },
12
+ {
13
+ title: "Ruin Test",
14
+ body: "The ultimate stress test: BTC drops 80% AND everything else drops 40% simultaneously. If you survive this, you survive anything.",
15
+ },
16
+ {
17
+ title: "Runway",
18
+ body: "Net position (portfolio after crash + cash reserve) divided by monthly burn rate.",
19
+ },
20
+ ];
21
+
22
+ export function InfoPanel() {
23
+ return (
24
+ <Card className="border-slate-700 bg-slate-800/50 shadow-none">
25
+ <CardContent className="p-4 sm:p-6">
26
+ <h3 className="text-base sm:text-lg font-semibold text-white mb-4">
27
+ How Does This Work?
28
+ </h3>
29
+ <div className="space-y-4">
30
+ {sections.map((s) => (
31
+ <div key={s.title}>
32
+ <h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">
33
+ {s.title}
34
+ </h4>
35
+ <p className="text-sm text-slate-300 leading-relaxed">
36
+ {s.body}
37
+ </p>
38
+ </div>
39
+ ))}
40
+ </div>
41
+ </CardContent>
42
+ </Card>
43
+ );
44
+ }
@@ -0,0 +1,134 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { PortfolioInput } from "../hooks/usePortfolio";
3
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4
+ import { Slider } from "@/components/ui/slider";
5
+
6
+ interface Props {
7
+ portfolio: PortfolioInput;
8
+ onUpdate: (updates: Partial<PortfolioInput>) => void;
9
+ savedAt?: number | null;
10
+ currencySymbol?: string;
11
+ }
12
+
13
+ function formatLabel(value: number, symbol = "$"): string {
14
+ if (value >= 1_000_000) return `${symbol}${(value / 1_000_000).toFixed(1)}M`;
15
+ if (value >= 1_000) return `${symbol}${(value / 1_000).toFixed(0)}K`;
16
+ return `${symbol}${value.toFixed(0)}`;
17
+ }
18
+
19
+ function InputField({
20
+ label,
21
+ value,
22
+ onChange,
23
+ min = 0,
24
+ max,
25
+ step = 1,
26
+ format,
27
+ suffix,
28
+ }: {
29
+ label: string;
30
+ value: number;
31
+ onChange: (v: number) => void;
32
+ min?: number;
33
+ max?: number;
34
+ step?: number;
35
+ format?: (v: number) => string;
36
+ suffix?: string;
37
+ }) {
38
+ return (
39
+ <div>
40
+ <div className="flex justify-between mb-2 sm:mb-1.5">
41
+ <label className="text-xs sm:text-sm text-slate-300">{label}</label>
42
+ <span className="text-xs sm:text-sm font-mono text-white">
43
+ {format ? format(value) : value}
44
+ {suffix}
45
+ </span>
46
+ </div>
47
+ <Slider
48
+ min={min}
49
+ max={max}
50
+ step={step}
51
+ value={[value]}
52
+ onValueChange={(vals) => onChange(vals[0])}
53
+ />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export function PortfolioForm({ portfolio, onUpdate, savedAt, currencySymbol = "$" }: Props) {
59
+ const [visible, setVisible] = useState(false);
60
+
61
+ useEffect(() => {
62
+ if (!savedAt) return;
63
+ setVisible(true);
64
+ const timer = setTimeout(() => setVisible(false), 2000);
65
+ return () => clearTimeout(timer);
66
+ }, [savedAt]);
67
+
68
+ return (
69
+ <Card className="border-slate-700 bg-slate-800/50 shadow-none">
70
+ <CardHeader className="p-4 sm:p-6 pb-0 sm:pb-0">
71
+ <div className="flex items-center justify-between">
72
+ <CardTitle className="text-base sm:text-lg text-white">Portfolio</CardTitle>
73
+ <span
74
+ className={`text-xs text-green-400 transition-opacity duration-500 ${visible ? "opacity-100" : "opacity-0"}`}
75
+ >
76
+ &#10003; Saved
77
+ </span>
78
+ </div>
79
+ </CardHeader>
80
+
81
+ <CardContent className="p-4 sm:p-6 pt-4 sm:pt-5 space-y-4 sm:space-y-5">
82
+ <InputField
83
+ label="Total Value"
84
+ value={portfolio.totalValueUsd}
85
+ onChange={(v) => onUpdate({ totalValueUsd: v })}
86
+ min={100_000}
87
+ max={50_000_000}
88
+ step={100_000}
89
+ format={(v) => formatLabel(v, currencySymbol)}
90
+ />
91
+
92
+ <InputField
93
+ label="Bitcoin Allocation"
94
+ value={portfolio.btcPercentage}
95
+ onChange={(v) => onUpdate({ btcPercentage: v })}
96
+ min={0}
97
+ max={100}
98
+ step={1}
99
+ suffix="%"
100
+ />
101
+
102
+ <InputField
103
+ label="Monthly Burn"
104
+ value={portfolio.monthlyBurnUsd}
105
+ onChange={(v) => onUpdate({ monthlyBurnUsd: v })}
106
+ min={0}
107
+ max={500_000}
108
+ step={1_000}
109
+ format={(v) => formatLabel(v, currencySymbol)}
110
+ />
111
+
112
+ <InputField
113
+ label="Liquid Reserve"
114
+ value={portfolio.liquidReserveUsd}
115
+ onChange={(v) => onUpdate({ liquidReserveUsd: v })}
116
+ min={0}
117
+ max={5_000_000}
118
+ step={10_000}
119
+ format={(v) => formatLabel(v, currencySymbol)}
120
+ />
121
+
122
+ <InputField
123
+ label="BTC Price"
124
+ value={portfolio.btcPriceUsd}
125
+ onChange={(v) => onUpdate({ btcPriceUsd: v })}
126
+ min={10_000}
127
+ max={200_000}
128
+ step={1_000}
129
+ format={(v) => formatLabel(v, currencySymbol)}
130
+ />
131
+ </CardContent>
132
+ </Card>
133
+ );
134
+ }
@@ -0,0 +1,382 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Card, CardContent } from "@/components/ui/card";
3
+
4
+ const API_BASE = "/api";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types (mirroring engine — no direct engine dep in web)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ interface PositionSizingInput {
11
+ totalValueUsd: number;
12
+ currentBtcPct: number;
13
+ targetBtcPct: number;
14
+ monthlyBurnUsd: number;
15
+ liquidReserveUsd: number;
16
+ btcPriceUsd: number;
17
+ dcaMonths: number;
18
+ }
19
+
20
+ interface PositionSizingResult {
21
+ currentBtcUsd: number;
22
+ targetBtcUsd: number;
23
+ gapUsd: number;
24
+ gapBtc: number;
25
+ dcaMonthlyUsd: number;
26
+ dcaMonthlyBtc: number;
27
+ dcaMonths: number;
28
+ postReallocationRuinTest: boolean;
29
+ postReallocationRunwayMonths: number;
30
+ convictionRung: string;
31
+ currentConvictionRung: string;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function fmt(n: number, decimals = 0): string {
39
+ if (!Number.isFinite(n)) return "\u221e";
40
+ return n.toLocaleString("en-US", {
41
+ minimumFractionDigits: decimals,
42
+ maximumFractionDigits: decimals,
43
+ });
44
+ }
45
+
46
+ function fmtUsd(n: number, symbol = "$"): string {
47
+ const abs = Math.abs(n);
48
+ if (abs >= 1_000_000) return `${symbol}${fmt(n / 1_000_000, 2)}M`;
49
+ if (abs >= 1_000) return `${symbol}${fmt(n / 1_000, 1)}K`;
50
+ return `${symbol}${fmt(n)}`;
51
+ }
52
+
53
+ function fmtBtc(n: number): string {
54
+ const abs = Math.abs(n);
55
+ const prefix = n < 0 ? "-" : "";
56
+ if (abs < 0.001) return `${prefix}${(abs * 1_000_000).toFixed(0)}\u00a0sats`;
57
+ return `${prefix}${abs.toFixed(4)}\u00a0BTC`;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Allocation bar: fills a horizontal bar with a BTC (orange) and non-BTC segment
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function AllocationBar({
65
+ btcPct,
66
+ label,
67
+ }: {
68
+ btcPct: number;
69
+ label: string;
70
+ }) {
71
+ return (
72
+ <div className="space-y-1.5">
73
+ <div className="flex justify-between text-xs text-slate-400">
74
+ <span>{label}</span>
75
+ <span className="font-mono text-slate-300">
76
+ BTC <span className="text-orange-400 font-bold">{btcPct.toFixed(1)}%</span>
77
+ {" / "}Other {(100 - btcPct).toFixed(1)}%
78
+ </span>
79
+ </div>
80
+ <div className="relative h-6 rounded-md overflow-hidden bg-slate-700/50">
81
+ {/* BTC segment */}
82
+ <div
83
+ className="absolute left-0 top-0 h-full bg-orange-500/70 transition-all duration-500 rounded-l-md"
84
+ style={{ width: `${Math.min(btcPct, 100)}%` }}
85
+ />
86
+ {/* Non-BTC segment fills the rest — already the default bg */}
87
+ {/* Marker at BTC pct */}
88
+ {btcPct > 0 && btcPct < 100 && (
89
+ <div
90
+ className="absolute top-0 h-full w-px bg-slate-300/40"
91
+ style={{ left: `${btcPct}%` }}
92
+ />
93
+ )}
94
+ {/* Label inside bar */}
95
+ <div className="absolute inset-0 flex items-center px-2 gap-1">
96
+ {btcPct >= 12 && (
97
+ <span className="text-xs font-semibold text-orange-100 drop-shadow">
98
+ BTC
99
+ </span>
100
+ )}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Slider with label
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function LabeledSlider({
112
+ label,
113
+ value,
114
+ min,
115
+ max,
116
+ step,
117
+ onChange,
118
+ display,
119
+ }: {
120
+ label: string;
121
+ value: number;
122
+ min: number;
123
+ max: number;
124
+ step: number;
125
+ onChange: (v: number) => void;
126
+ display: string;
127
+ }) {
128
+ return (
129
+ <div className="space-y-1.5">
130
+ <div className="flex justify-between items-center">
131
+ <span className="text-xs text-slate-400">{label}</span>
132
+ <span className="text-sm font-mono font-semibold text-orange-400">{display}</span>
133
+ </div>
134
+ <input
135
+ type="range"
136
+ min={min}
137
+ max={max}
138
+ step={step}
139
+ value={value}
140
+ onChange={(e) => onChange(Number(e.target.value))}
141
+ className="w-full h-1.5 rounded-full accent-orange-500 bg-slate-700 cursor-pointer"
142
+ />
143
+ <div className="flex justify-between text-xs text-slate-600">
144
+ <span>{min}%</span>
145
+ <span>{max}%</span>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Main component
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export interface PositionSizingProps {
156
+ totalValueUsd: number;
157
+ currentBtcPct: number;
158
+ monthlyBurnUsd: number;
159
+ liquidReserveUsd: number;
160
+ btcPriceUsd: number;
161
+ currencySymbol?: string;
162
+ }
163
+
164
+ const DCA_OPTIONS = [1, 3, 6, 12, 24] as const;
165
+
166
+ export function PositionSizing({
167
+ totalValueUsd,
168
+ currentBtcPct,
169
+ monthlyBurnUsd,
170
+ liquidReserveUsd,
171
+ btcPriceUsd,
172
+ currencySymbol = "$",
173
+ }: PositionSizingProps) {
174
+ const [targetBtcPct, setTargetBtcPct] = useState(() => Math.min(currentBtcPct + 5, 100));
175
+ const [dcaMonths, setDcaMonths] = useState<(typeof DCA_OPTIONS)[number]>(6);
176
+ const [result, setResult] = useState<PositionSizingResult | null>(null);
177
+ const [loading, setLoading] = useState(false);
178
+
179
+ const calculate = useCallback(
180
+ async (target: number, months: number) => {
181
+ if (!btcPriceUsd || !totalValueUsd) return;
182
+ setLoading(true);
183
+ try {
184
+ const body: PositionSizingInput = {
185
+ totalValueUsd,
186
+ currentBtcPct,
187
+ targetBtcPct: target,
188
+ monthlyBurnUsd,
189
+ liquidReserveUsd,
190
+ btcPriceUsd,
191
+ dcaMonths: months,
192
+ };
193
+ const res = await fetch(`${API_BASE}/position-sizing`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify(body),
197
+ });
198
+ if (!res.ok) throw new Error(`API ${res.status}`);
199
+ setResult(await res.json());
200
+ } catch (err) {
201
+ console.error("position-sizing error:", err);
202
+ } finally {
203
+ setLoading(false);
204
+ }
205
+ },
206
+ [totalValueUsd, currentBtcPct, monthlyBurnUsd, liquidReserveUsd, btcPriceUsd],
207
+ );
208
+
209
+ // Recalculate whenever inputs change
210
+ useEffect(() => {
211
+ calculate(targetBtcPct, dcaMonths);
212
+ }, [calculate, targetBtcPct, dcaMonths]);
213
+
214
+ const isBuying = result ? result.gapUsd >= 0 : targetBtcPct >= currentBtcPct;
215
+ const gapAbs = result ? Math.abs(result.gapUsd) : 0;
216
+
217
+ return (
218
+ <Card className="border-slate-700 bg-slate-800/50 shadow-none">
219
+ <CardContent className="p-4 sm:p-6 space-y-6">
220
+ {/* Header */}
221
+ <div>
222
+ <h3 className="text-base sm:text-lg font-semibold text-white mb-0.5">
223
+ Position Sizing
224
+ </h3>
225
+ <p className="text-xs text-slate-400">
226
+ Plan your path from current to target Bitcoin allocation
227
+ </p>
228
+ </div>
229
+
230
+ {/* Target slider */}
231
+ <LabeledSlider
232
+ label="Target BTC allocation"
233
+ value={targetBtcPct}
234
+ min={0}
235
+ max={100}
236
+ step={1}
237
+ onChange={setTargetBtcPct}
238
+ display={`${targetBtcPct}%`}
239
+ />
240
+
241
+ {/* Allocation comparison bars */}
242
+ <div className="space-y-3">
243
+ <AllocationBar btcPct={currentBtcPct} label="Current" />
244
+ <AllocationBar btcPct={targetBtcPct} label="Target" />
245
+ </div>
246
+
247
+ {/* Gap summary */}
248
+ {result && (
249
+ <div
250
+ className={`rounded-xl px-4 py-3 border transition-colors duration-300 ${
251
+ isBuying
252
+ ? "bg-emerald-950/50 border-emerald-500/30"
253
+ : "bg-amber-950/50 border-amber-500/30"
254
+ }`}
255
+ >
256
+ <div className="flex items-start gap-3">
257
+ <span
258
+ className={`text-2xl mt-0.5 ${isBuying ? "text-emerald-400" : "text-amber-400"}`}
259
+ >
260
+ {isBuying ? "\u2191" : "\u2193"}
261
+ </span>
262
+ <div className="flex-1 min-w-0">
263
+ <p
264
+ className={`text-sm font-semibold ${isBuying ? "text-emerald-400" : "text-amber-400"}`}
265
+ >
266
+ {isBuying ? "Buy" : "Sell"}{" "}
267
+ {fmtUsd(gapAbs, currencySymbol)}{" "}
268
+ of Bitcoin
269
+ </p>
270
+ <p className="text-xs text-slate-400 mt-0.5">
271
+ {fmtBtc(Math.abs(result.gapBtc))} at current price
272
+ </p>
273
+ <div className="mt-1 flex gap-3 text-xs text-slate-500">
274
+ <span>
275
+ {result.currentConvictionRung}
276
+ {" \u2192 "}
277
+ <span className="text-orange-400 font-medium">{result.convictionRung}</span>
278
+ </span>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ )}
284
+
285
+ {/* DCA breakdown */}
286
+ <div className="space-y-3">
287
+ <p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
288
+ DCA Schedule
289
+ </p>
290
+ {/* Month selector */}
291
+ <div className="flex gap-2">
292
+ {DCA_OPTIONS.map((m) => (
293
+ <button
294
+ key={m}
295
+ type="button"
296
+ onClick={() => setDcaMonths(m)}
297
+ className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
298
+ dcaMonths === m
299
+ ? "bg-orange-500/20 border-orange-500/50 text-orange-400"
300
+ : "bg-slate-800 border-slate-700 text-slate-500 hover:border-slate-600 hover:text-slate-400"
301
+ }`}
302
+ >
303
+ {m}mo
304
+ </button>
305
+ ))}
306
+ </div>
307
+
308
+ {/* Monthly amounts */}
309
+ {result && (
310
+ <div className="grid grid-cols-2 gap-3">
311
+ <div className="rounded-lg bg-slate-900/60 px-3 py-2.5 border border-slate-700/60">
312
+ <p className="text-xs text-slate-500">Monthly {isBuying ? "buy" : "sell"}</p>
313
+ <p className="text-lg font-bold font-mono text-white mt-0.5">
314
+ {fmtUsd(Math.abs(result.dcaMonthlyUsd), currencySymbol)}
315
+ </p>
316
+ <p className="text-xs text-slate-500 mt-0.5">
317
+ {fmtBtc(Math.abs(result.dcaMonthlyBtc))} / mo
318
+ </p>
319
+ </div>
320
+ <div className="rounded-lg bg-slate-900/60 px-3 py-2.5 border border-slate-700/60">
321
+ <p className="text-xs text-slate-500">Duration</p>
322
+ <p className="text-lg font-bold font-mono text-white mt-0.5">
323
+ {result.dcaMonths} months
324
+ </p>
325
+ <p className="text-xs text-slate-500 mt-0.5">
326
+ Total {fmtUsd(gapAbs, currencySymbol)}
327
+ </p>
328
+ </div>
329
+ </div>
330
+ )}
331
+ </div>
332
+
333
+ {/* Post-reallocation ruin test */}
334
+ {result && (
335
+ <div
336
+ className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-300 ${
337
+ result.postReallocationRuinTest
338
+ ? "bg-emerald-500/10 border-emerald-500/30"
339
+ : "bg-red-500/10 border-red-500/30"
340
+ }`}
341
+ >
342
+ <span
343
+ className={`text-2xl flex-shrink-0 ${
344
+ result.postReallocationRuinTest ? "text-emerald-400" : "text-red-400"
345
+ }`}
346
+ >
347
+ {result.postReallocationRuinTest ? "\u2713" : "\u2717"}
348
+ </span>
349
+ <div className="flex-1 min-w-0">
350
+ <p
351
+ className={`text-sm font-semibold ${
352
+ result.postReallocationRuinTest ? "text-emerald-400" : "text-red-400"
353
+ }`}
354
+ >
355
+ Ruin Test After Reallocation:{" "}
356
+ {result.postReallocationRuinTest ? "PASS" : "FAIL"}
357
+ </p>
358
+ <p className="text-xs text-slate-400 mt-0.5">
359
+ {result.postReallocationRunwayMonths === Infinity
360
+ ? "\u221e months"
361
+ : `${fmt(result.postReallocationRunwayMonths)} months`}{" "}
362
+ runway at {targetBtcPct}% BTC under worst-case crash
363
+ </p>
364
+ {!result.postReallocationRuinTest && (
365
+ <p className="text-xs text-red-400/70 mt-1">
366
+ Target allocation leaves insufficient runway — consider a lower target or larger reserve
367
+ </p>
368
+ )}
369
+ </div>
370
+ </div>
371
+ )}
372
+
373
+ {/* Loading state overlay */}
374
+ {loading && (
375
+ <div className="flex justify-center py-2">
376
+ <div className="w-4 h-4 rounded-full border-2 border-orange-400/30 border-t-orange-400 animate-spin" />
377
+ </div>
378
+ )}
379
+ </CardContent>
380
+ </Card>
381
+ );
382
+ }
@@ -0,0 +1,90 @@
1
+ import type { SurvivalResult } from "../hooks/usePortfolio";
2
+
3
+ function formatMonths(months: number): string {
4
+ if (months === Infinity) return "\u221e";
5
+ if (months > 120) return "120+";
6
+ return `${Math.round(months)}`;
7
+ }
8
+
9
+ function getScoreColor(maxDrawdown: number, ruinPassed: boolean) {
10
+ if (!ruinPassed) return { bg: "from-red-950/80 to-red-900/40", border: "border-red-500/30", text: "text-red-400", glow: "shadow-red-500/20" };
11
+ if (maxDrawdown >= 80) return { bg: "from-emerald-950/80 to-emerald-900/40", border: "border-emerald-500/30", text: "text-emerald-400", glow: "shadow-emerald-500/20" };
12
+ if (maxDrawdown >= 70) return { bg: "from-emerald-950/60 to-emerald-900/30", border: "border-emerald-500/20", text: "text-emerald-400", glow: "shadow-emerald-500/10" };
13
+ if (maxDrawdown >= 50) return { bg: "from-amber-950/60 to-amber-900/30", border: "border-amber-500/20", text: "text-amber-400", glow: "shadow-amber-500/10" };
14
+ return { bg: "from-red-950/60 to-red-900/30", border: "border-red-500/20", text: "text-red-400", glow: "shadow-red-500/10" };
15
+ }
16
+
17
+ export function SurvivalHero({ result }: { result: SurvivalResult }) {
18
+ const { maxSurvivableDrawdown, ruinTestPassed, scenarios } = result;
19
+ const worstCase = scenarios[scenarios.length - 1]; // 80% drawdown
20
+ const colors = getScoreColor(maxSurvivableDrawdown, ruinTestPassed);
21
+
22
+ return (
23
+ <div className={`relative overflow-hidden rounded-2xl border ${colors.border} bg-gradient-to-br ${colors.bg} p-6 sm:p-8 transition-all duration-500 shadow-lg ${colors.glow}`}>
24
+ {/* Background decoration */}
25
+ <div className="absolute inset-0 opacity-5">
26
+ <div className="absolute top-0 right-0 w-64 h-64 rounded-full bg-white blur-3xl translate-x-1/3 -translate-y-1/3" />
27
+ </div>
28
+
29
+ <div className="relative flex flex-col sm:flex-row items-center gap-6 sm:gap-10">
30
+ {/* Survival Score */}
31
+ <div className="text-center sm:text-left flex-shrink-0">
32
+ <p className="text-xs uppercase tracking-widest text-slate-400 mb-2">Max Survivable Crash</p>
33
+ <div className="flex items-baseline gap-1">
34
+ <span className={`text-7xl sm:text-8xl font-black tabular-nums tracking-tight ${colors.text} transition-colors duration-500`}>
35
+ {maxSurvivableDrawdown}
36
+ </span>
37
+ <span className={`text-3xl sm:text-4xl font-bold ${colors.text} opacity-70`}>%</span>
38
+ </div>
39
+ <p className="text-sm text-slate-400 mt-1">drawdown before forced selling</p>
40
+ </div>
41
+
42
+ {/* Divider */}
43
+ <div className="hidden sm:block w-px h-24 bg-slate-700/50" />
44
+
45
+ {/* Ruin test + key stats */}
46
+ <div className="flex-1 space-y-4">
47
+ {/* Ruin test badge */}
48
+ <div className={`inline-flex items-center gap-3 rounded-xl px-4 py-3 ${
49
+ ruinTestPassed
50
+ ? "bg-emerald-500/10 border border-emerald-500/30"
51
+ : "bg-red-500/10 border border-red-500/30"
52
+ } transition-all duration-500`}>
53
+ <span className={`text-3xl ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
54
+ {ruinTestPassed ? "\u2713" : "\u2717"}
55
+ </span>
56
+ <div>
57
+ <div className={`text-sm font-bold ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
58
+ Ruin Test {ruinTestPassed ? "PASSED" : "FAILED"}
59
+ </div>
60
+ <div className="text-xs text-slate-400">
61
+ BTC -80% &amp; assets -40% simultaneously
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ {/* Key stats row */}
67
+ <div className="flex gap-6">
68
+ <div>
69
+ <p className="text-xs text-slate-500 uppercase tracking-wider">Worst-Case Runway</p>
70
+ <p className={`text-2xl font-bold tabular-nums ${
71
+ worstCase.runwayMonths >= 18 ? "text-emerald-400"
72
+ : worstCase.runwayMonths >= 6 ? "text-amber-400"
73
+ : "text-red-400"
74
+ } transition-colors duration-500`}>
75
+ {formatMonths(worstCase.runwayMonths)}
76
+ <span className="text-sm font-normal text-slate-500 ml-1">months</span>
77
+ </p>
78
+ </div>
79
+ <div>
80
+ <p className="text-xs text-slate-500 uppercase tracking-wider">Survival Threshold</p>
81
+ <p className="text-2xl font-bold text-slate-300">
82
+ 18<span className="text-sm font-normal text-slate-500 ml-1">months</span>
83
+ </p>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,47 @@
1
+ import type { SurvivalResult } from "../hooks/usePortfolio";
2
+ import { Card, CardContent } from "@/components/ui/card";
3
+ import { Badge } from "@/components/ui/badge";
4
+
5
+ export function SurvivalSummary({ result }: { result: SurvivalResult; currencySymbol?: string }) {
6
+ const { maxSurvivableDrawdown, ruinTestPassed } = result;
7
+
8
+ return (
9
+ <Card className="border-slate-700 bg-slate-800/50 shadow-none">
10
+ <CardContent className="p-4 sm:p-6">
11
+ <div className="flex flex-col gap-4 sm:gap-0 sm:flex-row sm:items-center sm:justify-between">
12
+ <div>
13
+ <h3 className="text-base sm:text-lg font-semibold text-white mb-2 sm:mb-1">Survival Summary</h3>
14
+ <p className="text-xs sm:text-sm text-slate-300">
15
+ You survive down to a{" "}
16
+ <span
17
+ className={`font-bold text-lg sm:text-xl ${maxSurvivableDrawdown >= 70 ? "text-emerald-400" : maxSurvivableDrawdown >= 50 ? "text-amber-400" : "text-red-400"}`}
18
+ >
19
+ {maxSurvivableDrawdown}%
20
+ </span>{" "}
21
+ crash before needing to sell
22
+ </p>
23
+ </div>
24
+
25
+ <Badge
26
+ variant={ruinTestPassed ? "outline" : "destructive"}
27
+ className={`flex items-center gap-2 px-3 sm:px-4 py-2 rounded-lg flex-shrink-0 h-auto text-sm ${
28
+ ruinTestPassed
29
+ ? "bg-emerald-900/30 border-emerald-500/50 text-emerald-400 hover:bg-emerald-900/30"
30
+ : "bg-red-900/30 border-red-500/50 text-red-400 hover:bg-red-900/30"
31
+ }`}
32
+ >
33
+ <span className="text-xl sm:text-2xl">
34
+ {ruinTestPassed ? "\u2713" : "\u2717"}
35
+ </span>
36
+ <div className="text-left">
37
+ <div className={`text-xs sm:text-sm font-bold ${ruinTestPassed ? "text-emerald-400" : "text-red-400"}`}>
38
+ Ruin Test {ruinTestPassed ? "PASSED" : "FAILED"}
39
+ </div>
40
+ <div className="text-xs text-slate-400 font-normal">BTC -80% + Assets -40%</div>
41
+ </div>
42
+ </Badge>
43
+ </div>
44
+ </CardContent>
45
+ </Card>
46
+ );
47
+ }