@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timecell/web",
3
- "version": "0.1.2",
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.1
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: Crash detailscollapsed by default */}
89
+ {/* ZONE 3: Market IntelligenceTemperature + 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 4: Framework — below fold */}
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 function TemperatureGauge() {
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
- const calculate = useCallback(async (p: PortfolioInput, h: HedgePosition[]) => {
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("Failed to calculate:", err);
67
- setError("Could not reach the calculation engine. Is the API server running on port 3737?");
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
- try {
75
- const res = await fetch(`${API_BASE}/portfolio`);
76
- const data = await res.json();
77
- setPortfolio(data.portfolio);
78
- setHedgePositions(data.hedgePositions || []);
79
- if (data.currency?.symbol) setCurrencySymbol(data.currency.symbol);
80
- await calculate(data.portfolio, data.hedgePositions || []);
81
- } catch {
82
- // Use defaults and calculate
83
- await calculate(portfolio, hedgePositions);
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"),