@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,283 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Card, CardContent } from "@/components/ui/card";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ interface TemperatureData {
9
+ score: number;
10
+ zone: "Extreme Fear" | "Fear" | "Neutral" | "Greed" | "Extreme Greed";
11
+ mvrv: number;
12
+ rhodl: number;
13
+ dataSource: string;
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Map a 0–100 score to a CSS colour string via a blue→green→yellow→orange→red gradient. */
21
+ function scoreToColor(score: number): string {
22
+ if (score < 20) return "#3b82f6"; // blue-500
23
+ if (score < 40) return "#22c55e"; // green-500
24
+ if (score < 60) return "#eab308"; // yellow-500
25
+ if (score < 75) return "#f97316"; // orange-500
26
+ return "#ef4444"; // red-500
27
+ }
28
+
29
+ /** Zone-specific Tailwind text colour class. */
30
+ function zoneTextClass(zone: TemperatureData["zone"]): string {
31
+ switch (zone) {
32
+ case "Extreme Fear":
33
+ return "text-blue-400";
34
+ case "Fear":
35
+ return "text-green-400";
36
+ case "Neutral":
37
+ return "text-yellow-400";
38
+ case "Greed":
39
+ return "text-orange-400";
40
+ case "Extreme Greed":
41
+ return "text-red-400";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Convert a 0–100 score to polar co-ordinates on a semi-circle.
47
+ *
48
+ * The semi-circle spans from 180° (left, score 0) to 0° (right, score 100),
49
+ * sweeping through the top. We use standard SVG arc math with the arc
50
+ * centre at (100, 100) and radius 80.
51
+ */
52
+ function scoreToPoint(score: number, radius: number, cx: number, cy: number): { x: number; y: number } {
53
+ // angle: 180° at score=0, 0° at score=100
54
+ const angleDeg = 180 - score * 1.8;
55
+ const angleRad = (angleDeg * Math.PI) / 180;
56
+ return {
57
+ x: cx + radius * Math.cos(angleRad),
58
+ y: cy - radius * Math.sin(angleRad),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Build an SVG arc path for the colour-coded foreground arc (score portion).
64
+ */
65
+ function buildArcPath(score: number, radius: number, cx: number, cy: number): string {
66
+ const start = scoreToPoint(0, radius, cx, cy);
67
+ const end = scoreToPoint(score, radius, cx, cy);
68
+ const largeArc = score > 50 ? 1 : 0;
69
+ return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Zone band segments rendered behind the needle
74
+ // ---------------------------------------------------------------------------
75
+
76
+ interface Band {
77
+ from: number;
78
+ to: number;
79
+ color: string;
80
+ }
81
+
82
+ const BANDS: Band[] = [
83
+ { from: 0, to: 20, color: "#3b82f6" }, // blue — Extreme Fear
84
+ { from: 20, to: 40, color: "#22c55e" }, // green — Fear
85
+ { from: 40, to: 60, color: "#eab308" }, // yellow — Neutral
86
+ { from: 60, to: 75, color: "#f97316" }, // orange — Greed
87
+ { from: 75, to: 100, color: "#ef4444" }, // red — Extreme Greed
88
+ ];
89
+
90
+ function BandArc({ from, to, radius, cx, cy }: { from: number; to: number; radius: number; cx: number; cy: number }) {
91
+ const start = scoreToPoint(from, radius, cx, cy);
92
+ const end = scoreToPoint(to, radius, cx, cy);
93
+ const span = to - from;
94
+ const largeArc = span > 50 ? 1 : 0;
95
+ const d = `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
96
+ const band = BANDS.find((b) => b.from === from);
97
+ return <path d={d} stroke={band?.color ?? "#64748b"} strokeWidth="10" fill="none" strokeOpacity="0.25" />;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Gauge SVG
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function GaugeSVG({ score }: { score: number }) {
105
+ const CX = 100;
106
+ const CY = 100;
107
+ const RADIUS = 75;
108
+ const arcColor = scoreToColor(score);
109
+
110
+ // Needle tip at the score position, base at centre
111
+ const tip = scoreToPoint(score, RADIUS - 8, CX, CY);
112
+
113
+ return (
114
+ <svg viewBox="0 0 200 115" className="w-full max-w-xs mx-auto" aria-hidden="true">
115
+ {/* Background track */}
116
+ <path
117
+ d={`M ${CX - RADIUS} ${CY} A ${RADIUS} ${RADIUS} 0 0 1 ${CX + RADIUS} ${CY}`}
118
+ stroke="#1e293b"
119
+ strokeWidth="12"
120
+ fill="none"
121
+ />
122
+
123
+ {/* Coloured zone bands */}
124
+ {BANDS.map((b) => (
125
+ <BandArc key={b.from} from={b.from} to={b.to} radius={RADIUS} cx={CX} cy={CY} />
126
+ ))}
127
+
128
+ {/* Filled arc to current score */}
129
+ <path
130
+ d={buildArcPath(score, RADIUS, CX, CY)}
131
+ stroke={arcColor}
132
+ strokeWidth="12"
133
+ fill="none"
134
+ strokeLinecap="round"
135
+ style={{ transition: "all 0.6s ease" }}
136
+ />
137
+
138
+ {/* Needle */}
139
+ <line
140
+ x1={CX}
141
+ y1={CY}
142
+ x2={tip.x}
143
+ y2={tip.y}
144
+ stroke={arcColor}
145
+ strokeWidth="2.5"
146
+ strokeLinecap="round"
147
+ style={{ transition: "all 0.6s ease" }}
148
+ />
149
+ {/* Needle hub */}
150
+ <circle cx={CX} cy={CY} r="5" fill={arcColor} style={{ transition: "fill 0.6s ease" }} />
151
+ <circle cx={CX} cy={CY} r="2.5" fill="#0f172a" />
152
+
153
+ {/* Tick marks at zone boundaries */}
154
+ {[0, 20, 40, 60, 75, 100].map((tick) => {
155
+ const outer = scoreToPoint(tick, RADIUS + 10, CX, CY);
156
+ const inner = scoreToPoint(tick, RADIUS - 10, CX, CY);
157
+ return (
158
+ <line
159
+ key={tick}
160
+ x1={inner.x}
161
+ y1={inner.y}
162
+ x2={outer.x}
163
+ y2={outer.y}
164
+ stroke="#475569"
165
+ strokeWidth="1"
166
+ />
167
+ );
168
+ })}
169
+
170
+ {/* Min/max labels */}
171
+ <text x={CX - RADIUS - 4} y={CY + 14} fontSize="9" fill="#64748b" textAnchor="middle">
172
+ 0
173
+ </text>
174
+ <text x={CX + RADIUS + 4} y={CY + 14} fontSize="9" fill="#64748b" textAnchor="middle">
175
+ 100
176
+ </text>
177
+ </svg>
178
+ );
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Main component
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export function TemperatureGauge() {
186
+ const [data, setData] = useState<TemperatureData | null>(null);
187
+ const [loading, setLoading] = useState(true);
188
+ const [error, setError] = useState<string | null>(null);
189
+
190
+ useEffect(() => {
191
+ let cancelled = false;
192
+
193
+ async function fetchTemperature() {
194
+ try {
195
+ const res = await fetch("/api/temperature");
196
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
197
+ const json = (await res.json()) as TemperatureData;
198
+ if (!cancelled) {
199
+ setData(json);
200
+ setError(null);
201
+ }
202
+ } catch (err) {
203
+ if (!cancelled) {
204
+ setError(err instanceof Error ? err.message : "Failed to load temperature");
205
+ }
206
+ } finally {
207
+ if (!cancelled) setLoading(false);
208
+ }
209
+ }
210
+
211
+ fetchTemperature();
212
+ return () => {
213
+ cancelled = true;
214
+ };
215
+ }, []);
216
+
217
+ return (
218
+ <Card className="border-slate-700 bg-slate-800/50 shadow-none">
219
+ <CardContent className="p-4 sm:p-6">
220
+ <h3 className="text-base sm:text-lg font-semibold text-white mb-1">
221
+ Market Temperature
222
+ </h3>
223
+ <p className="text-xs text-slate-400 mb-4">
224
+ MVRV (60%) + RHODL (40%) composite — where are we in the cycle?
225
+ </p>
226
+
227
+ {loading && (
228
+ <div className="flex items-center justify-center h-40 text-slate-500 text-sm">
229
+ Loading…
230
+ </div>
231
+ )}
232
+
233
+ {error && (
234
+ <div className="flex items-center justify-center h-40 text-red-400 text-sm">
235
+ {error}
236
+ </div>
237
+ )}
238
+
239
+ {!loading && !error && data && (
240
+ <div className="flex flex-col items-center gap-1">
241
+ {/* Semi-circular gauge */}
242
+ <GaugeSVG score={data.score} />
243
+
244
+ {/* Score + zone */}
245
+ <div className="text-center -mt-2">
246
+ <span
247
+ className="text-5xl font-black tabular-nums tracking-tight transition-colors duration-500"
248
+ style={{ color: scoreToColor(data.score) }}
249
+ >
250
+ {data.score}
251
+ </span>
252
+ <span className="text-lg font-semibold text-slate-400 ml-1">/ 100</span>
253
+ <p className={`text-sm font-semibold mt-1 transition-colors duration-500 ${zoneTextClass(data.zone)}`}>
254
+ {data.zone}
255
+ </p>
256
+ </div>
257
+
258
+ {/* Component breakdown */}
259
+ <div className="mt-4 w-full grid grid-cols-2 gap-2 text-center">
260
+ <div className="rounded-lg bg-slate-900/50 border border-slate-700/50 px-3 py-2">
261
+ <p className="text-xs text-slate-500 uppercase tracking-wider mb-0.5">MVRV</p>
262
+ <p className="text-lg font-bold text-slate-200 tabular-nums">{data.mvrv.toFixed(2)}</p>
263
+ <p className="text-xs text-slate-500">60% weight</p>
264
+ </div>
265
+ <div className="rounded-lg bg-slate-900/50 border border-slate-700/50 px-3 py-2">
266
+ <p className="text-xs text-slate-500 uppercase tracking-wider mb-0.5">RHODL</p>
267
+ <p className="text-lg font-bold text-slate-200 tabular-nums">{data.rhodl.toFixed(2)}</p>
268
+ <p className="text-xs text-slate-500">40% weight</p>
269
+ </div>
270
+ </div>
271
+
272
+ {/* Data source note */}
273
+ {data.dataSource === "mock" && (
274
+ <p className="mt-3 text-xs text-slate-600 text-center">
275
+ Mock data — live on-chain feeds coming soon
276
+ </p>
277
+ )}
278
+ </div>
279
+ )}
280
+ </CardContent>
281
+ </Card>
282
+ );
283
+ }
@@ -0,0 +1,36 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
@@ -0,0 +1,76 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-xl border bg-card text-card-foreground shadow",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Card.displayName = "Card"
19
+
20
+ const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
27
+ {...props}
28
+ />
29
+ ))
30
+ CardHeader.displayName = "CardHeader"
31
+
32
+ const CardTitle = React.forwardRef<
33
+ HTMLDivElement,
34
+ React.HTMLAttributes<HTMLDivElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <div
37
+ ref={ref}
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ ))
42
+ CardTitle.displayName = "CardTitle"
43
+
44
+ const CardDescription = React.forwardRef<
45
+ HTMLDivElement,
46
+ React.HTMLAttributes<HTMLDivElement>
47
+ >(({ className, ...props }, ref) => (
48
+ <div
49
+ ref={ref}
50
+ className={cn("text-sm text-muted-foreground", className)}
51
+ {...props}
52
+ />
53
+ ))
54
+ CardDescription.displayName = "CardDescription"
55
+
56
+ const CardContent = React.forwardRef<
57
+ HTMLDivElement,
58
+ React.HTMLAttributes<HTMLDivElement>
59
+ >(({ className, ...props }, ref) => (
60
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
61
+ ))
62
+ CardContent.displayName = "CardContent"
63
+
64
+ const CardFooter = React.forwardRef<
65
+ HTMLDivElement,
66
+ React.HTMLAttributes<HTMLDivElement>
67
+ >(({ className, ...props }, ref) => (
68
+ <div
69
+ ref={ref}
70
+ className={cn("flex items-center p-6 pt-0", className)}
71
+ {...props}
72
+ />
73
+ ))
74
+ CardFooter.displayName = "CardFooter"
75
+
76
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,29 @@
1
+ import * as React from "react"
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const Separator = React.forwardRef<
7
+ React.ElementRef<typeof SeparatorPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
9
+ >(
10
+ (
11
+ { className, orientation = "horizontal", decorative = true, ...props },
12
+ ref
13
+ ) => (
14
+ <SeparatorPrimitive.Root
15
+ ref={ref}
16
+ decorative={decorative}
17
+ orientation={orientation}
18
+ className={cn(
19
+ "shrink-0 bg-border",
20
+ orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ )
27
+ Separator.displayName = SeparatorPrimitive.Root.displayName
28
+
29
+ export { Separator }
@@ -0,0 +1,26 @@
1
+ import * as React from "react"
2
+ import * as SliderPrimitive from "@radix-ui/react-slider"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const Slider = React.forwardRef<
7
+ React.ElementRef<typeof SliderPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <SliderPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ "relative flex w-full touch-none select-none items-center",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-700">
19
+ <SliderPrimitive.Range className="absolute h-full bg-orange-500" />
20
+ </SliderPrimitive.Track>
21
+ <SliderPrimitive.Thumb className="block h-[18px] w-[18px] rounded-full border-2 border-orange-500 bg-orange-500 shadow transition-all hover:scale-[1.3] hover:shadow-[0_0_0_4px_rgba(249,115,22,0.2)] hover:bg-orange-400 active:scale-[1.1] active:shadow-[0_0_0_6px_rgba(249,115,22,0.3)] active:bg-orange-600 focus-visible:outline-none focus-visible:shadow-[0_0_0_4px_rgba(249,115,22,0.4)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer" />
22
+ </SliderPrimitive.Root>
23
+ ))
24
+ Slider.displayName = SliderPrimitive.Root.displayName
25
+
26
+ export { Slider }
@@ -0,0 +1,32 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const TooltipProvider = TooltipPrimitive.Provider
9
+
10
+ const Tooltip = TooltipPrimitive.Root
11
+
12
+ const TooltipTrigger = TooltipPrimitive.Trigger
13
+
14
+ const TooltipContent = React.forwardRef<
15
+ React.ElementRef<typeof TooltipPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
17
+ >(({ className, sideOffset = 4, ...props }, ref) => (
18
+ <TooltipPrimitive.Portal>
19
+ <TooltipPrimitive.Content
20
+ ref={ref}
21
+ sideOffset={sideOffset}
22
+ className={cn(
23
+ "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
24
+ className
25
+ )}
26
+ {...props}
27
+ />
28
+ </TooltipPrimitive.Portal>
29
+ ))
30
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName
31
+
32
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,110 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ const API_BASE = "/api";
4
+
5
+ export interface PortfolioInput {
6
+ totalValueUsd: number;
7
+ btcPercentage: number;
8
+ monthlyBurnUsd: number;
9
+ liquidReserveUsd: number;
10
+ btcPriceUsd: number;
11
+ }
12
+
13
+ export interface HedgePosition {
14
+ strikeUsd: number;
15
+ quantityBtc: number;
16
+ expiryDate?: string;
17
+ }
18
+
19
+ export interface CrashScenario {
20
+ drawdownPct: number;
21
+ btcPriceAtCrash: number;
22
+ btcValueAfterCrash: number;
23
+ nonBtcValueAfterCrash: number;
24
+ portfolioValueAfterCrash: number;
25
+ hedgePayoff: number;
26
+ netPosition: number;
27
+ runwayMonths: number;
28
+ survivalStatus: "safe" | "warning" | "critical";
29
+ }
30
+
31
+ export interface SurvivalResult {
32
+ portfolio: PortfolioInput;
33
+ scenarios: CrashScenario[];
34
+ maxSurvivableDrawdown: number;
35
+ ruinTestPassed: boolean;
36
+ }
37
+
38
+ export function usePortfolio() {
39
+ const [portfolio, setPortfolio] = useState<PortfolioInput>({
40
+ totalValueUsd: 5_000_000,
41
+ btcPercentage: 35,
42
+ monthlyBurnUsd: 25_000,
43
+ liquidReserveUsd: 600_000,
44
+ btcPriceUsd: 84_000,
45
+ });
46
+ const [hedgePositions, setHedgePositions] = useState<HedgePosition[]>([]);
47
+ const [currencySymbol, setCurrencySymbol] = useState("$");
48
+ const [result, setResult] = useState<SurvivalResult | null>(null);
49
+ const [loading, setLoading] = useState(false);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [savedAt, setSavedAt] = useState<number | null>(null);
52
+
53
+ const calculate = useCallback(async (p: PortfolioInput, h: HedgePosition[]) => {
54
+ setLoading(true);
55
+ setError(null);
56
+ try {
57
+ const res = await fetch(`${API_BASE}/crash-survival`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ portfolio: p, hedgePositions: h }),
61
+ });
62
+ if (!res.ok) throw new Error(`API returned ${res.status}`);
63
+ const data: SurvivalResult = await res.json();
64
+ setResult(data);
65
+ } 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?");
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ }, []);
72
+
73
+ 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);
84
+ }
85
+ }, [calculate]);
86
+
87
+ const updatePortfolio = useCallback(
88
+ async (updates: Partial<PortfolioInput>) => {
89
+ const updated = { ...portfolio, ...updates };
90
+ setPortfolio(updated);
91
+ await calculate(updated, hedgePositions);
92
+ setSavedAt(Date.now());
93
+ },
94
+ [portfolio, hedgePositions, calculate],
95
+ );
96
+
97
+ return {
98
+ portfolio,
99
+ hedgePositions,
100
+ currencySymbol,
101
+ result,
102
+ loading,
103
+ error,
104
+ savedAt,
105
+ loadPortfolio,
106
+ updatePortfolio,
107
+ setHedgePositions,
108
+ calculate,
109
+ };
110
+ }