@timecell/web 0.1.2 → 0.2.0
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/package.json +2 -1
- package/src/App.tsx +31 -3
- package/src/components/ActionPlan.tsx +211 -0
- package/src/components/BtcPriceTicker.tsx +124 -0
- package/src/components/TemperatureGauge.tsx +6 -1
- package/src/hooks/useBtcPrice.ts +78 -0
- package/src/hooks/usePortfolio.ts +113 -17
- package/src/lib/engine-standalone.ts +92 -0
- package/vite.config.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timecell/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TimeCell web application",
|
|
6
6
|
"scripts": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@timecell/engine": "*",
|
|
12
13
|
"@radix-ui/react-separator": "^1.1.8",
|
|
13
14
|
"@radix-ui/react-slider": "^1.3.6",
|
|
14
15
|
"@radix-ui/react-tooltip": "^1.2.8",
|
package/src/App.tsx
CHANGED
|
@@ -4,6 +4,10 @@ import { SurvivalHero } from "./components/SurvivalHero";
|
|
|
4
4
|
import { PortfolioForm } from "./components/PortfolioForm";
|
|
5
5
|
import { CrashChart } from "./components/CrashChart";
|
|
6
6
|
import { CrashGrid } from "./components/CrashGrid";
|
|
7
|
+
import { BtcPriceTicker } from "./components/BtcPriceTicker";
|
|
8
|
+
import { TemperatureGauge } from "./components/TemperatureGauge";
|
|
9
|
+
import { PositionSizing } from "./components/PositionSizing";
|
|
10
|
+
import { ActionPlan } from "./components/ActionPlan";
|
|
7
11
|
import { ConvictionLadder } from "./components/ConvictionLadder";
|
|
8
12
|
import { InfoPanel } from "./components/InfoPanel";
|
|
9
13
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
@@ -11,6 +15,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
11
15
|
export default function App() {
|
|
12
16
|
const { portfolio, currencySymbol, result, loading, error, savedAt, loadPortfolio, updatePortfolio } = usePortfolio();
|
|
13
17
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
18
|
+
const [temperatureScore, setTemperatureScore] = useState(55);
|
|
14
19
|
|
|
15
20
|
useEffect(() => {
|
|
16
21
|
loadPortfolio();
|
|
@@ -25,8 +30,9 @@ export default function App() {
|
|
|
25
30
|
<div className="flex items-center gap-3">
|
|
26
31
|
<img src="/logo.png" alt="TimeCell" className="h-8 brightness-110" />
|
|
27
32
|
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
|
|
28
|
-
v0.
|
|
33
|
+
v0.2
|
|
29
34
|
</span>
|
|
35
|
+
<BtcPriceTicker fallbackPrice={portfolio.btcPriceUsd} currencySymbol={currencySymbol} />
|
|
30
36
|
</div>
|
|
31
37
|
<span className="text-xs sm:text-sm text-slate-500">Crash Survival Calculator</span>
|
|
32
38
|
</div>
|
|
@@ -80,7 +86,29 @@ export default function App() {
|
|
|
80
86
|
</div>
|
|
81
87
|
</div>
|
|
82
88
|
|
|
83
|
-
{/* ZONE 3:
|
|
89
|
+
{/* ZONE 3: Market Intelligence — Temperature + Position Sizing + Action Plan */}
|
|
90
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
91
|
+
<TemperatureGauge onTemperatureChange={setTemperatureScore} />
|
|
92
|
+
<PositionSizing
|
|
93
|
+
totalValueUsd={portfolio.totalValueUsd}
|
|
94
|
+
currentBtcPct={portfolio.btcPercentage}
|
|
95
|
+
monthlyBurnUsd={portfolio.monthlyBurnUsd}
|
|
96
|
+
liquidReserveUsd={portfolio.liquidReserveUsd}
|
|
97
|
+
btcPriceUsd={portfolio.btcPriceUsd}
|
|
98
|
+
currencySymbol={currencySymbol}
|
|
99
|
+
/>
|
|
100
|
+
<ActionPlan
|
|
101
|
+
btcPercentage={portfolio.btcPercentage}
|
|
102
|
+
ruinTestPassed={result?.ruinTestPassed ?? true}
|
|
103
|
+
runwayMonths={result?.scenarios?.[result.scenarios.length - 1]?.runwayMonths ?? Infinity}
|
|
104
|
+
temperatureScore={temperatureScore}
|
|
105
|
+
liquidReserveUsd={portfolio.liquidReserveUsd}
|
|
106
|
+
monthlyBurnUsd={portfolio.monthlyBurnUsd}
|
|
107
|
+
totalValueUsd={portfolio.totalValueUsd}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* ZONE 4: Crash details — collapsed by default */}
|
|
84
112
|
{result && !loading && (
|
|
85
113
|
<div>
|
|
86
114
|
<button
|
|
@@ -98,7 +126,7 @@ export default function App() {
|
|
|
98
126
|
</div>
|
|
99
127
|
)}
|
|
100
128
|
|
|
101
|
-
{/* ZONE
|
|
129
|
+
{/* ZONE 5: Framework — below fold */}
|
|
102
130
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
103
131
|
<ConvictionLadder btcPercentage={portfolio.btcPercentage} />
|
|
104
132
|
<InfoPanel />
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
3
|
+
import { generateActionPlanLocally } from "../lib/engine-standalone";
|
|
4
|
+
|
|
5
|
+
const API_BASE = "/api";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Standalone detection (same pattern as usePortfolio.ts)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function detectStandalone(): boolean {
|
|
12
|
+
if (import.meta.env.VITE_STANDALONE === "true") return true;
|
|
13
|
+
if (typeof window !== "undefined" && window.location.pathname.startsWith("/app")) return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types (mirroring engine — no direct engine dep in web)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
type ActionSeverity = "red" | "amber" | "green";
|
|
22
|
+
|
|
23
|
+
interface ActionItem {
|
|
24
|
+
severity: ActionSeverity;
|
|
25
|
+
message: string;
|
|
26
|
+
rule: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Severity styling
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function severityDotClass(severity: ActionSeverity): string {
|
|
34
|
+
switch (severity) {
|
|
35
|
+
case "red":
|
|
36
|
+
return "bg-red-400";
|
|
37
|
+
case "amber":
|
|
38
|
+
return "bg-amber-400";
|
|
39
|
+
case "green":
|
|
40
|
+
return "bg-emerald-400";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function severityTextClass(severity: ActionSeverity): string {
|
|
45
|
+
switch (severity) {
|
|
46
|
+
case "red":
|
|
47
|
+
return "text-red-400";
|
|
48
|
+
case "amber":
|
|
49
|
+
return "text-amber-400";
|
|
50
|
+
case "green":
|
|
51
|
+
return "text-emerald-400";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function severityBorderClass(severity: ActionSeverity): string {
|
|
56
|
+
switch (severity) {
|
|
57
|
+
case "red":
|
|
58
|
+
return "border-l-red-400";
|
|
59
|
+
case "amber":
|
|
60
|
+
return "border-l-amber-400";
|
|
61
|
+
case "green":
|
|
62
|
+
return "border-l-emerald-400";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Main component
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export interface ActionPlanProps {
|
|
71
|
+
btcPercentage: number;
|
|
72
|
+
ruinTestPassed: boolean;
|
|
73
|
+
runwayMonths: number;
|
|
74
|
+
temperatureScore: number;
|
|
75
|
+
liquidReserveUsd: number;
|
|
76
|
+
monthlyBurnUsd: number;
|
|
77
|
+
totalValueUsd: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ActionPlan({
|
|
81
|
+
btcPercentage,
|
|
82
|
+
ruinTestPassed,
|
|
83
|
+
runwayMonths,
|
|
84
|
+
temperatureScore,
|
|
85
|
+
liquidReserveUsd,
|
|
86
|
+
monthlyBurnUsd,
|
|
87
|
+
totalValueUsd,
|
|
88
|
+
}: ActionPlanProps) {
|
|
89
|
+
const [items, setItems] = useState<ActionItem[]>([]);
|
|
90
|
+
const [loading, setLoading] = useState(false);
|
|
91
|
+
const [error, setError] = useState<string | null>(null);
|
|
92
|
+
|
|
93
|
+
// Sticky standalone flag — once API fails, stay in standalone mode
|
|
94
|
+
const standaloneRef = useRef<boolean | null>(null);
|
|
95
|
+
|
|
96
|
+
function isStandalone(): boolean {
|
|
97
|
+
if (standaloneRef.current !== null) return standaloneRef.current;
|
|
98
|
+
standaloneRef.current = detectStandalone();
|
|
99
|
+
return standaloneRef.current;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const input = {
|
|
103
|
+
btcPercentage,
|
|
104
|
+
ruinTestPassed,
|
|
105
|
+
runwayMonths,
|
|
106
|
+
temperatureScore,
|
|
107
|
+
liquidReserveUsd,
|
|
108
|
+
monthlyBurnUsd,
|
|
109
|
+
totalValueUsd,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const calculateStandalone = useCallback(() => {
|
|
113
|
+
try {
|
|
114
|
+
const result = generateActionPlanLocally(input);
|
|
115
|
+
setItems(result as ActionItem[]);
|
|
116
|
+
setError(null);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("action-plan standalone error:", err);
|
|
119
|
+
setError("Calculation failed.");
|
|
120
|
+
}
|
|
121
|
+
}, [btcPercentage, ruinTestPassed, runwayMonths, temperatureScore, liquidReserveUsd, monthlyBurnUsd, totalValueUsd]);
|
|
122
|
+
|
|
123
|
+
const calculate = useCallback(async () => {
|
|
124
|
+
if (isStandalone()) {
|
|
125
|
+
setLoading(true);
|
|
126
|
+
calculateStandalone();
|
|
127
|
+
setLoading(false);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setLoading(true);
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(`${API_BASE}/action-plan`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify(input),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) throw new Error(`API ${res.status}`);
|
|
139
|
+
setItems(await res.json());
|
|
140
|
+
setError(null);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("action-plan API failed, falling back to standalone:", err);
|
|
143
|
+
// Fall back to local calculation and stay in standalone mode
|
|
144
|
+
standaloneRef.current = true;
|
|
145
|
+
calculateStandalone();
|
|
146
|
+
} finally {
|
|
147
|
+
setLoading(false);
|
|
148
|
+
}
|
|
149
|
+
}, [calculateStandalone, btcPercentage, ruinTestPassed, runwayMonths, temperatureScore, liquidReserveUsd, monthlyBurnUsd, totalValueUsd]);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
calculate();
|
|
153
|
+
}, [calculate]);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Card className="border-slate-700 bg-slate-800/50 shadow-none">
|
|
157
|
+
<CardContent className="p-4 sm:p-6">
|
|
158
|
+
{/* Header */}
|
|
159
|
+
<h3 className="text-base sm:text-lg font-semibold text-white mb-0.5">
|
|
160
|
+
What Should I Do?
|
|
161
|
+
</h3>
|
|
162
|
+
<p className="text-xs text-slate-400 mb-4">
|
|
163
|
+
Personalized actions based on your portfolio, market temperature, and risk profile
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
{/* Loading */}
|
|
167
|
+
{loading && (
|
|
168
|
+
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
|
|
169
|
+
<div className="w-4 h-4 rounded-full border-2 border-orange-400/30 border-t-orange-400 animate-spin mr-2" />
|
|
170
|
+
Analyzing...
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Error */}
|
|
175
|
+
{error && (
|
|
176
|
+
<div className="flex items-center justify-center h-32 text-red-400 text-sm">
|
|
177
|
+
{error}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{/* Action items */}
|
|
182
|
+
{!loading && !error && items.length > 0 && (
|
|
183
|
+
<div className="space-y-2.5">
|
|
184
|
+
{items.map((item) => (
|
|
185
|
+
<div
|
|
186
|
+
key={item.rule}
|
|
187
|
+
className={`border-l-2 ${severityBorderClass(item.severity)} rounded-r-lg bg-slate-900/40 px-3 py-2.5`}
|
|
188
|
+
>
|
|
189
|
+
<div className="flex items-start gap-2.5">
|
|
190
|
+
<span
|
|
191
|
+
className={`mt-1.5 flex-shrink-0 w-2 h-2 rounded-full ${severityDotClass(item.severity)}`}
|
|
192
|
+
/>
|
|
193
|
+
<p className={`text-sm leading-snug ${severityTextClass(item.severity)}`}>
|
|
194
|
+
{item.message}
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* All clear */}
|
|
203
|
+
{!loading && !error && items.length === 0 && (
|
|
204
|
+
<div className="flex items-center justify-center h-32 text-emerald-400 text-sm font-medium">
|
|
205
|
+
All clear — your portfolio is well-positioned.
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</CardContent>
|
|
209
|
+
</Card>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useBtcPrice } from "@/hooks/useBtcPrice";
|
|
3
|
+
|
|
4
|
+
interface BtcPriceTickerProps {
|
|
5
|
+
/** Fallback price if live fetch fails (e.g. portfolio's btcPriceUsd) */
|
|
6
|
+
fallbackPrice?: number;
|
|
7
|
+
/** Currency symbol to display (e.g. "$", "₹", "€") */
|
|
8
|
+
currencySymbol?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type FlashDirection = "up" | "down" | null;
|
|
12
|
+
|
|
13
|
+
function formatPrice(price: number, symbol: string): string {
|
|
14
|
+
return `${symbol}${price.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function secondsAgo(date: Date): number {
|
|
18
|
+
return Math.floor((Date.now() - date.getTime()) / 1_000);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function useSecondsAgo(lastUpdated: Date | null): number | null {
|
|
22
|
+
const [secs, setSecs] = useState<number | null>(
|
|
23
|
+
lastUpdated ? secondsAgo(lastUpdated) : null,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!lastUpdated) return;
|
|
28
|
+
setSecs(secondsAgo(lastUpdated));
|
|
29
|
+
const t = setInterval(() => setSecs(secondsAgo(lastUpdated)), 5_000);
|
|
30
|
+
return () => clearInterval(t);
|
|
31
|
+
}, [lastUpdated]);
|
|
32
|
+
|
|
33
|
+
return secs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function BtcPriceTicker({
|
|
37
|
+
fallbackPrice,
|
|
38
|
+
currencySymbol = "$",
|
|
39
|
+
}: BtcPriceTickerProps) {
|
|
40
|
+
const { price, loading, lastUpdated, source } = useBtcPrice(fallbackPrice);
|
|
41
|
+
const prevPriceRef = useRef<number | null>(null);
|
|
42
|
+
const [flash, setFlash] = useState<FlashDirection>(null);
|
|
43
|
+
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const secs = useSecondsAgo(lastUpdated);
|
|
45
|
+
|
|
46
|
+
// Detect price direction change and trigger flash
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (price === null) return;
|
|
49
|
+
const prev = prevPriceRef.current;
|
|
50
|
+
|
|
51
|
+
if (prev !== null && prev !== price) {
|
|
52
|
+
const direction: FlashDirection = price > prev ? "up" : "down";
|
|
53
|
+
setFlash(direction);
|
|
54
|
+
|
|
55
|
+
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
|
56
|
+
flashTimerRef.current = setTimeout(() => setFlash(null), 1_500);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prevPriceRef.current = price;
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
|
63
|
+
};
|
|
64
|
+
}, [price]);
|
|
65
|
+
|
|
66
|
+
// Price text colour transitions: green flash on up, red flash on down, neutral otherwise
|
|
67
|
+
const priceColorClass =
|
|
68
|
+
flash === "up"
|
|
69
|
+
? "text-emerald-400"
|
|
70
|
+
: flash === "down"
|
|
71
|
+
? "text-red-400"
|
|
72
|
+
: "text-white";
|
|
73
|
+
|
|
74
|
+
const lastUpdatedLabel =
|
|
75
|
+
secs === null
|
|
76
|
+
? null
|
|
77
|
+
: secs < 10
|
|
78
|
+
? "just now"
|
|
79
|
+
: secs < 60
|
|
80
|
+
? `${secs}s ago`
|
|
81
|
+
: `${Math.floor(secs / 60)}m ago`;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
{/* Pulsing live indicator dot */}
|
|
86
|
+
<span className="relative flex h-2 w-2 flex-shrink-0">
|
|
87
|
+
<span
|
|
88
|
+
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
|
|
89
|
+
source === null ? "bg-slate-500" : "bg-emerald-500"
|
|
90
|
+
}`}
|
|
91
|
+
/>
|
|
92
|
+
<span
|
|
93
|
+
className={`relative inline-flex h-2 w-2 rounded-full ${
|
|
94
|
+
source === null ? "bg-slate-500" : "bg-emerald-500"
|
|
95
|
+
}`}
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
|
|
99
|
+
<div className="flex flex-col items-end leading-none">
|
|
100
|
+
{/* Price */}
|
|
101
|
+
<span
|
|
102
|
+
className={`tabular-nums text-sm font-semibold transition-colors duration-700 ${priceColorClass}`}
|
|
103
|
+
>
|
|
104
|
+
{loading && price === null ? (
|
|
105
|
+
<span className="inline-block h-4 w-20 animate-pulse rounded bg-slate-700" />
|
|
106
|
+
) : price !== null ? (
|
|
107
|
+
formatPrice(price, currencySymbol)
|
|
108
|
+
) : (
|
|
109
|
+
<span className="text-slate-500">—</span>
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
|
|
113
|
+
{/* "Last updated" sub-label */}
|
|
114
|
+
<span className="text-[10px] text-slate-500 mt-0.5">
|
|
115
|
+
{lastUpdatedLabel
|
|
116
|
+
? `updated ${lastUpdatedLabel}`
|
|
117
|
+
: source === null
|
|
118
|
+
? "fetching…"
|
|
119
|
+
: null}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -182,7 +182,11 @@ function GaugeSVG({ score }: { score: number }) {
|
|
|
182
182
|
// Main component
|
|
183
183
|
// ---------------------------------------------------------------------------
|
|
184
184
|
|
|
185
|
-
export
|
|
185
|
+
export interface TemperatureGaugeProps {
|
|
186
|
+
onTemperatureChange?: (score: number) => void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function TemperatureGauge({ onTemperatureChange }: TemperatureGaugeProps = {}) {
|
|
186
190
|
const [data, setData] = useState<TemperatureData | null>(null);
|
|
187
191
|
const [loading, setLoading] = useState(true);
|
|
188
192
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -198,6 +202,7 @@ export function TemperatureGauge() {
|
|
|
198
202
|
if (!cancelled) {
|
|
199
203
|
setData(json);
|
|
200
204
|
setError(null);
|
|
205
|
+
onTemperatureChange?.(json.score);
|
|
201
206
|
}
|
|
202
207
|
} catch (err) {
|
|
203
208
|
if (!cancelled) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export interface BtcPriceState {
|
|
4
|
+
price: number | null;
|
|
5
|
+
loading: boolean;
|
|
6
|
+
lastUpdated: Date | null;
|
|
7
|
+
source: "api" | "coingecko" | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
11
|
+
const API_PRICE_URL = "/api/price";
|
|
12
|
+
const COINGECKO_URL =
|
|
13
|
+
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd";
|
|
14
|
+
|
|
15
|
+
async function fetchFromApi(): Promise<{ price: number; source: "api" } | null> {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(API_PRICE_URL);
|
|
18
|
+
if (!res.ok) return null;
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
const price = typeof data?.price === "number" ? data.price : null;
|
|
21
|
+
if (!price) return null;
|
|
22
|
+
return { price, source: "api" };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fetchFromCoinGecko(): Promise<{ price: number; source: "coingecko" } | null> {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(COINGECKO_URL);
|
|
31
|
+
if (!res.ok) return null;
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
const price = data?.bitcoin?.usd ?? null;
|
|
34
|
+
if (!price) return null;
|
|
35
|
+
return { price, source: "coingecko" as const };
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useBtcPrice(fallbackPrice?: number): BtcPriceState {
|
|
42
|
+
const [state, setState] = useState<BtcPriceState>({
|
|
43
|
+
price: fallbackPrice ?? null,
|
|
44
|
+
loading: true,
|
|
45
|
+
lastUpdated: null,
|
|
46
|
+
source: null,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
50
|
+
|
|
51
|
+
const refresh = useCallback(async () => {
|
|
52
|
+
// Try local API first; if unavailable (Vercel / standalone) fall back to CoinGecko direct
|
|
53
|
+
const result = (await fetchFromApi()) ?? (await fetchFromCoinGecko());
|
|
54
|
+
|
|
55
|
+
setState((prev) => ({
|
|
56
|
+
price: result?.price ?? prev.price ?? fallbackPrice ?? null,
|
|
57
|
+
loading: false,
|
|
58
|
+
lastUpdated: result ? new Date() : prev.lastUpdated,
|
|
59
|
+
source: result?.source ?? prev.source,
|
|
60
|
+
}));
|
|
61
|
+
}, [fallbackPrice]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Kick off immediately on mount
|
|
65
|
+
refresh();
|
|
66
|
+
|
|
67
|
+
// Then refresh every 60 seconds
|
|
68
|
+
intervalRef.current = setInterval(refresh, REFRESH_INTERVAL_MS);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
if (intervalRef.current !== null) {
|
|
72
|
+
clearInterval(intervalRef.current);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}, [refresh]);
|
|
76
|
+
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
calculateLocally,
|
|
4
|
+
loadPortfolioFromStorage,
|
|
5
|
+
savePortfolioToStorage,
|
|
6
|
+
fetchBtcPrice,
|
|
7
|
+
} from "../lib/engine-standalone";
|
|
2
8
|
|
|
3
9
|
const API_BASE = "/api";
|
|
4
10
|
|
|
@@ -35,6 +41,18 @@ export interface SurvivalResult {
|
|
|
35
41
|
ruinTestPassed: boolean;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Mode detection: standalone (static site) vs API (CLI-served)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function detectStandalone(): boolean {
|
|
49
|
+
// Explicit env flag set during Vercel build
|
|
50
|
+
if (import.meta.env.VITE_STANDALONE === "true") return true;
|
|
51
|
+
// Path-based detection (/app/ prefix means hosted static site)
|
|
52
|
+
if (typeof window !== "undefined" && window.location.pathname.startsWith("/app")) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export function usePortfolio() {
|
|
39
57
|
const [portfolio, setPortfolio] = useState<PortfolioInput>({
|
|
40
58
|
totalValueUsd: 5_000_000,
|
|
@@ -50,7 +68,36 @@ export function usePortfolio() {
|
|
|
50
68
|
const [error, setError] = useState<string | null>(null);
|
|
51
69
|
const [savedAt, setSavedAt] = useState<number | null>(null);
|
|
52
70
|
|
|
53
|
-
|
|
71
|
+
// Track whether we're in standalone mode. Once detected, stays sticky.
|
|
72
|
+
const standaloneRef = useRef<boolean | null>(null);
|
|
73
|
+
|
|
74
|
+
function isStandalone(): boolean {
|
|
75
|
+
if (standaloneRef.current !== null) return standaloneRef.current;
|
|
76
|
+
standaloneRef.current = detectStandalone();
|
|
77
|
+
return standaloneRef.current;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Standalone calculate (engine imported directly)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const calculateStandalone = useCallback((p: PortfolioInput, h: HedgePosition[]) => {
|
|
84
|
+
setLoading(true);
|
|
85
|
+
setError(null);
|
|
86
|
+
try {
|
|
87
|
+
const data = calculateLocally(p, h);
|
|
88
|
+
setResult(data as SurvivalResult);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("Engine calculation error:", err);
|
|
91
|
+
setError("Calculation failed. Please check your inputs.");
|
|
92
|
+
} finally {
|
|
93
|
+
setLoading(false);
|
|
94
|
+
}
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// API calculate (existing fetch-based flow)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
const calculateApi = useCallback(async (p: PortfolioInput, h: HedgePosition[]) => {
|
|
54
101
|
setLoading(true);
|
|
55
102
|
setError(null);
|
|
56
103
|
try {
|
|
@@ -63,35 +110,84 @@ export function usePortfolio() {
|
|
|
63
110
|
const data: SurvivalResult = await res.json();
|
|
64
111
|
setResult(data);
|
|
65
112
|
} catch (err) {
|
|
66
|
-
console.error("
|
|
67
|
-
|
|
113
|
+
console.error("API call failed, falling back to standalone:", err);
|
|
114
|
+
// Fallback: switch to standalone mode permanently
|
|
115
|
+
standaloneRef.current = true;
|
|
116
|
+
calculateStandalone(p, h);
|
|
68
117
|
} finally {
|
|
69
118
|
setLoading(false);
|
|
70
119
|
}
|
|
71
|
-
}, []);
|
|
120
|
+
}, [calculateStandalone]);
|
|
72
121
|
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Unified calculate
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
const calculate = useCallback(
|
|
126
|
+
async (p: PortfolioInput, h: HedgePosition[]) => {
|
|
127
|
+
if (isStandalone()) {
|
|
128
|
+
calculateStandalone(p, h);
|
|
129
|
+
} else {
|
|
130
|
+
await calculateApi(p, h);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[calculateStandalone, calculateApi],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Load portfolio (localStorage or API)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
73
139
|
const loadPortfolio = useCallback(async () => {
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
if (isStandalone()) {
|
|
141
|
+
const saved = loadPortfolioFromStorage();
|
|
142
|
+
// Try to get a fresh BTC price
|
|
143
|
+
const btcPrice = await fetchBtcPrice();
|
|
144
|
+
const p = { ...saved.portfolio, btcPriceUsd: btcPrice };
|
|
145
|
+
setPortfolio(p);
|
|
146
|
+
setHedgePositions(saved.hedgePositions || []);
|
|
147
|
+
if (saved.currency?.symbol) setCurrencySymbol(saved.currency.symbol);
|
|
148
|
+
calculateStandalone(p, saved.hedgePositions || []);
|
|
149
|
+
} else {
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`${API_BASE}/portfolio`);
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
setPortfolio(data.portfolio);
|
|
154
|
+
setHedgePositions(data.hedgePositions || []);
|
|
155
|
+
if (data.currency?.symbol) setCurrencySymbol(data.currency.symbol);
|
|
156
|
+
await calculate(data.portfolio, data.hedgePositions || []);
|
|
157
|
+
} catch {
|
|
158
|
+
// API unavailable — switch to standalone
|
|
159
|
+
standaloneRef.current = true;
|
|
160
|
+
const saved = loadPortfolioFromStorage();
|
|
161
|
+
const btcPrice = await fetchBtcPrice();
|
|
162
|
+
const p = { ...saved.portfolio, btcPriceUsd: btcPrice };
|
|
163
|
+
setPortfolio(p);
|
|
164
|
+
setHedgePositions(saved.hedgePositions || []);
|
|
165
|
+
if (saved.currency?.symbol) setCurrencySymbol(saved.currency.symbol);
|
|
166
|
+
calculateStandalone(p, saved.hedgePositions || []);
|
|
167
|
+
}
|
|
84
168
|
}
|
|
85
|
-
}, [calculate]);
|
|
169
|
+
}, [calculate, calculateStandalone]);
|
|
86
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Update portfolio
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
87
174
|
const updatePortfolio = useCallback(
|
|
88
175
|
async (updates: Partial<PortfolioInput>) => {
|
|
89
176
|
const updated = { ...portfolio, ...updates };
|
|
90
177
|
setPortfolio(updated);
|
|
91
178
|
await calculate(updated, hedgePositions);
|
|
92
179
|
setSavedAt(Date.now());
|
|
180
|
+
|
|
181
|
+
// Persist to localStorage in standalone mode
|
|
182
|
+
if (isStandalone()) {
|
|
183
|
+
savePortfolioToStorage({
|
|
184
|
+
portfolio: updated,
|
|
185
|
+
hedgePositions,
|
|
186
|
+
currency: { code: "USD", symbol: currencySymbol },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
93
189
|
},
|
|
94
|
-
[portfolio, hedgePositions, calculate],
|
|
190
|
+
[portfolio, hedgePositions, currencySymbol, calculate],
|
|
95
191
|
);
|
|
96
192
|
|
|
97
193
|
return {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Standalone Engine Adapter
|
|
3
|
+
// Imports engine directly (no API server needed) for static site deployment.
|
|
4
|
+
// Handles BTC price fetching and localStorage persistence.
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
calculateCrashSurvival,
|
|
9
|
+
generateActionPlan,
|
|
10
|
+
DEMO_PORTFOLIO,
|
|
11
|
+
DEMO_HEDGE_POSITIONS,
|
|
12
|
+
DEMO_BTC_PRICE,
|
|
13
|
+
type PortfolioInput,
|
|
14
|
+
type HedgePosition,
|
|
15
|
+
type SurvivalResult,
|
|
16
|
+
type ActionPlanInput,
|
|
17
|
+
type ActionItem,
|
|
18
|
+
} from "@timecell/engine";
|
|
19
|
+
|
|
20
|
+
const STORAGE_KEY = "timecell:portfolio";
|
|
21
|
+
|
|
22
|
+
interface SavedPortfolio {
|
|
23
|
+
portfolio: PortfolioInput;
|
|
24
|
+
hedgePositions: HedgePosition[];
|
|
25
|
+
currency?: { code: string; symbol: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// BTC Price
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
let cachedBtcPrice: number | null = null;
|
|
33
|
+
|
|
34
|
+
export async function fetchBtcPrice(): Promise<number> {
|
|
35
|
+
if (cachedBtcPrice) return cachedBtcPrice;
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(
|
|
38
|
+
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
|
39
|
+
);
|
|
40
|
+
if (!res.ok) return DEMO_BTC_PRICE;
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
const price = data?.bitcoin?.usd;
|
|
43
|
+
if (typeof price === "number" && price > 0) {
|
|
44
|
+
cachedBtcPrice = price;
|
|
45
|
+
return price;
|
|
46
|
+
}
|
|
47
|
+
return DEMO_BTC_PRICE;
|
|
48
|
+
} catch {
|
|
49
|
+
return DEMO_BTC_PRICE;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// LocalStorage Persistence
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export function loadPortfolioFromStorage(): SavedPortfolio {
|
|
58
|
+
try {
|
|
59
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
60
|
+
if (raw) return JSON.parse(raw) as SavedPortfolio;
|
|
61
|
+
} catch {
|
|
62
|
+
// corrupted data — fall through to defaults
|
|
63
|
+
}
|
|
64
|
+
return { portfolio: DEMO_PORTFOLIO, hedgePositions: DEMO_HEDGE_POSITIONS };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function savePortfolioToStorage(data: SavedPortfolio): void {
|
|
68
|
+
try {
|
|
69
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
70
|
+
} catch {
|
|
71
|
+
// localStorage full or unavailable — silently ignore
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Calculation (direct engine call)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function calculateLocally(
|
|
80
|
+
portfolio: PortfolioInput,
|
|
81
|
+
hedgePositions: HedgePosition[],
|
|
82
|
+
): SurvivalResult {
|
|
83
|
+
return calculateCrashSurvival(portfolio, hedgePositions);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Action Plan (direct engine call)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function generateActionPlanLocally(input: ActionPlanInput): ActionItem[] {
|
|
91
|
+
return generateActionPlan(input);
|
|
92
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -3,8 +3,15 @@ import { defineConfig } from "vite";
|
|
|
3
3
|
import react from "@vitejs/plugin-react";
|
|
4
4
|
import tailwindcss from "@tailwindcss/vite";
|
|
5
5
|
|
|
6
|
+
const isVercel = process.env.VERCEL === "1";
|
|
7
|
+
|
|
6
8
|
export default defineConfig({
|
|
7
9
|
plugins: [react(), tailwindcss()],
|
|
10
|
+
base: isVercel ? "/app/" : "/",
|
|
11
|
+
define: {
|
|
12
|
+
// Expose standalone flag to client code
|
|
13
|
+
"import.meta.env.VITE_STANDALONE": JSON.stringify(isVercel ? "true" : "false"),
|
|
14
|
+
},
|
|
8
15
|
resolve: {
|
|
9
16
|
alias: {
|
|
10
17
|
"@": path.resolve(__dirname, "./src"),
|