Skip to content

Commit 2ec3f76

Browse files
committed
fix: resolve issues #362, #363, #365, #367 assigned to daniel007-ai
- #367: Add proactive session expiry detection with visibility-based 60-second polling in WalletContext. Auto-disconnect and show reconnect toast via SessionWarningToast when Freighter session expires. - #365: Add window focus re-validation for scheduled start time in create-stream form. Start time is re-validated when the form regains focus to catch past timestamps after tab inactivity. - #363: Add stream health score indicator (0-100) calculated from deposit remaining, time remaining, and top-up history. Color-coded badge (green/amber/red) on dashboard StreamCard and stream detail page. New StreamHealthBadge component with interactive tooltip breakdown. - #362: Add collateral unlock countdown badge on stream detail page. Shows countdown to collateral release with lock/unlock states and claim CTA. New CollateralUnlockBadge component.
1 parent 8302900 commit 2ec3f76

8 files changed

Lines changed: 638 additions & 6 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
5+
interface CollateralUnlockBadgeProps {
6+
/** The stream's end_time as an ISO string. */
7+
endTime: string;
8+
/** Grace period in seconds after endTime before collateral unlocks. Defaults to 0. */
9+
gracePeriodSeconds?: number;
10+
/** Whether the current user is the sender who locked collateral. */
11+
isSender: boolean;
12+
/** Whether collateral is actually held for this stream. */
13+
hasCollateral: boolean;
14+
/** Collateral amount in stroops (for display). */
15+
collateralStroops?: number;
16+
/** Called when user clicks the claim button. */
17+
onClaim?: () => void;
18+
/** Whether a claim transaction is in progress. */
19+
claiming?: boolean;
20+
}
21+
22+
function computeUnlockRemaining(unlockTimeMs: number) {
23+
const diff = unlockTimeMs - Date.now();
24+
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, unlocked: true };
25+
const totalSeconds = Math.floor(diff / 1000);
26+
return {
27+
days: Math.floor(totalSeconds / 86400),
28+
hours: Math.floor((totalSeconds % 86400) / 3600),
29+
minutes: Math.floor((totalSeconds % 3600) / 60),
30+
seconds: totalSeconds % 60,
31+
unlocked: false,
32+
};
33+
}
34+
35+
export default function CollateralUnlockBadge({
36+
endTime,
37+
gracePeriodSeconds = 0,
38+
isSender,
39+
hasCollateral,
40+
collateralStroops,
41+
onClaim,
42+
claiming = false,
43+
}: CollateralUnlockBadgeProps) {
44+
const unlockTimeMs = new Date(endTime).getTime() + gracePeriodSeconds * 1000;
45+
const [remaining, setRemaining] = useState(() => computeUnlockRemaining(unlockTimeMs));
46+
const [pulse, setPulse] = useState(false);
47+
48+
// Tick every second
49+
useEffect(() => {
50+
const interval = setInterval(() => {
51+
const next = computeUnlockRemaining(unlockTimeMs);
52+
setRemaining(next);
53+
}, 1000);
54+
return () => clearInterval(interval);
55+
}, [unlockTimeMs]);
56+
57+
// Pulse animation when unlocked
58+
useEffect(() => {
59+
if (remaining.unlocked) {
60+
setPulse(true);
61+
const timeout = setTimeout(() => setPulse(false), 2000);
62+
return () => clearTimeout(timeout);
63+
}
64+
}, [remaining.unlocked]);
65+
66+
// Don't render if no collateral or user is not the sender
67+
if (!hasCollateral || !isSender) {
68+
return null;
69+
}
70+
71+
const collateralDisplay = collateralStroops
72+
? (collateralStroops / 10_000_000).toFixed(2)
73+
: null;
74+
75+
// Unlocked state
76+
if (remaining.unlocked) {
77+
return (
78+
<div
79+
className={`flex items-center gap-3 bg-green-900/40 border border-green-700/50 rounded-lg px-4 py-3 transition-all ${
80+
pulse ? "animate-pulse" : ""
81+
}`}
82+
role="status"
83+
aria-label="Collateral has been released"
84+
>
85+
<div className="flex-shrink-0">
86+
<svg
87+
className="h-6 w-6 text-green-400"
88+
xmlns="http://www.w3.org/2000/svg"
89+
viewBox="0 0 24 24"
90+
fill="none"
91+
stroke="currentColor"
92+
strokeWidth="2"
93+
strokeLinecap="round"
94+
strokeLinejoin="round"
95+
aria-hidden="true"
96+
>
97+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
98+
<polyline points="22 4 12 14.01 9 11.01" />
99+
</svg>
100+
</div>
101+
<div className="flex-1 min-w-0">
102+
<p className="text-sm font-semibold text-green-300">Collateral Released</p>
103+
<p className="text-xs text-green-400/70">
104+
{collateralDisplay
105+
? `${collateralDisplay} XLM available to claim`
106+
: "Your collateral is now available"}
107+
</p>
108+
</div>
109+
{onClaim && (
110+
<button
111+
onClick={onClaim}
112+
disabled={claiming}
113+
className="flex-shrink-0 bg-green-700 hover:bg-green-600 disabled:opacity-50 text-white text-xs font-medium px-3 py-1.5 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-400"
114+
>
115+
{claiming ? "Claiming…" : "Claim"}
116+
</button>
117+
)}
118+
</div>
119+
);
120+
}
121+
122+
// Countdown state
123+
const parts: { label: string; value: number }[] = [
124+
{ label: "d", value: remaining.days },
125+
{ label: "h", value: remaining.hours },
126+
{ label: "m", value: remaining.minutes },
127+
{ label: "s", value: remaining.seconds },
128+
];
129+
130+
return (
131+
<div
132+
className="bg-amber-900/30 border border-amber-700/40 rounded-lg px-4 py-3"
133+
role="status"
134+
aria-label="Collateral unlock countdown"
135+
>
136+
<div className="flex items-start gap-3">
137+
<div className="flex-shrink-0 mt-0.5">
138+
<svg
139+
className="h-5 w-5 text-amber-400"
140+
xmlns="http://www.w3.org/2000/svg"
141+
viewBox="0 0 24 24"
142+
fill="none"
143+
stroke="currentColor"
144+
strokeWidth="2"
145+
strokeLinecap="round"
146+
strokeLinejoin="round"
147+
aria-hidden="true"
148+
>
149+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
150+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
151+
</svg>
152+
</div>
153+
<div className="flex-1 min-w-0">
154+
<p className="text-sm font-semibold text-amber-300">Collateral Locked</p>
155+
{collateralDisplay && (
156+
<p className="text-xs text-amber-400/70 mt-0.5">{collateralDisplay} XLM locked</p>
157+
)}
158+
<div className="flex items-center gap-2 mt-2 font-mono" aria-label="Time until unlock">
159+
{parts.map((p) => (
160+
<span key={p.label} className="flex items-baseline gap-0.5">
161+
<span className="text-lg font-bold tabular-nums text-amber-200">
162+
{String(p.value).padStart(p.label === "d" ? 1 : 2, "0")}
163+
</span>
164+
<span className="text-[10px] uppercase tracking-wider text-amber-400/60">
165+
{p.label}
166+
</span>
167+
</span>
168+
))}
169+
</div>
170+
</div>
171+
</div>
172+
</div>
173+
);
174+
}

components/StreamCard.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import FiatDisplay from "@/components/FiatDisplay";
55
import { truncateAddress, formatStellarAmount } from "@/src/lib/sorostream";
66
import FederationName from "@/components/FederationName";
77
import { useBookmarks } from "@/src/context/BookmarksContext";
8+
import StreamHealthBadge, {
9+
calculateHealthScore,
10+
getHealthTier,
11+
} from "@/components/StreamHealthBadge";
12+
import { getMockStreamHistory } from "@/src/lib/sorostream";
813

914
interface StreamCardProps {
1015
id?: string;
@@ -17,6 +22,10 @@ interface StreamCardProps {
1722
onToggle?: (id: string) => void;
1823
/** Unix timestamp (seconds). When set and > now, a "Scheduled" badge is shown. */
1924
scheduledStartTime?: number;
25+
/** Stream start time ISO string. */
26+
startTime?: string;
27+
/** Stream end time ISO string. */
28+
endTime?: string;
2029
}
2130

2231
export default function StreamCard({
@@ -29,6 +38,8 @@ export default function StreamCard({
2938
selected = false,
3039
onToggle,
3140
scheduledStartTime,
41+
startTime,
42+
endTime,
3243
}: StreamCardProps) {
3344
const { isBookmarked, toggleBookmark } = useBookmarks();
3445
const bookmarked = isBookmarked(id);
@@ -37,6 +48,31 @@ export default function StreamCard({
3748
typeof scheduledStartTime === "number" &&
3849
scheduledStartTime > Math.floor(Date.now() / 1000);
3950

51+
// ── Health score calculation ──────────────────────────────────────────
52+
const healthScore = (() => {
53+
if (!startTime || !endTime || status === "Cancelled") return null;
54+
const now = Date.now();
55+
const totalDuration = new Date(endTime).getTime() - new Date(startTime).getTime();
56+
const elapsed = now - new Date(startTime).getTime();
57+
const timeRemainingRatio = totalDuration > 0
58+
? Math.max(0, Math.min(1, 1 - elapsed / totalDuration))
59+
: 0;
60+
// Estimate deposit remaining based on flow rate (simplified)
61+
const estimatedStreamed = flowRate * Math.max(0, (now - new Date(startTime).getTime()) / 1000);
62+
const depositRemainingRatio = deposit > 0
63+
? Math.max(0, Math.min(1, 1 - estimatedStreamed / deposit))
64+
: 0;
65+
// Approximate top-up count from mock history
66+
const history = getMockStreamHistory(id);
67+
const topUpCount = history.filter((e) => e.type === "top-up").length;
68+
return calculateHealthScore({
69+
depositRemainingRatio,
70+
timeRemainingRatio,
71+
topUpCount,
72+
});
73+
})();
74+
const healthTier = healthScore !== null ? getHealthTier(healthScore) : null;
75+
4076
/** Convert stroops → XLM (display value). */
4177
const toXlm = (val: number) => (val / 10_000_000).toFixed(2);
4278
const flowXlm = flowRate / 10_000_000;
@@ -97,6 +133,26 @@ export default function StreamCard({
97133
>
98134
{status}
99135
</span>
136+
{healthScore !== null && healthTier !== null && (() => {
137+
const now = Date.now();
138+
const totalDuration = endTime && startTime ? new Date(endTime).getTime() - new Date(startTime).getTime() : 0;
139+
const elapsed = startTime ? now - new Date(startTime).getTime() : 0;
140+
const timeRemainingRatio = totalDuration > 0 ? Math.max(0, Math.min(1, 1 - elapsed / totalDuration)) : 0;
141+
const estimatedStreamed = flowRate * Math.max(0, elapsed / 1000);
142+
const depositRemainingRatio = deposit > 0 ? Math.max(0, Math.min(1, 1 - estimatedStreamed / deposit)) : 0;
143+
const history = getMockStreamHistory(id);
144+
const topUpCount = history.filter((e) => e.type === "top-up").length;
145+
return (
146+
<StreamHealthBadge
147+
score={healthScore}
148+
tier={healthTier}
149+
depositRemainingRatio={depositRemainingRatio}
150+
timeRemainingRatio={timeRemainingRatio}
151+
topUpCount={topUpCount}
152+
compact
153+
/>
154+
);
155+
})()}
100156
</div>
101157
</div>
102158

0 commit comments

Comments
 (0)