Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/CountdownTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ export default function CountdownTimer({ endTime, className = "", initialSeconds
return (
<span
className={`font-mono text-sm font-semibold tabular-nums ${
isUrgent ? "text-amber-400" : "text-cyan-300"
isUrgent ? 'text-amber-400' : 'text-cyan-300'
} ${className}`}
>
{isExpired ? "Ended" : formattedTime}
{isExpired ? 'Ended' : formattedTime}
</span>
);
}
61 changes: 36 additions & 25 deletions src/components/RoundCard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@

// ISSUE: Wire place_bet() to Xelma TypeScript bindings (xelma-contract)
// ISSUE: Real-time round updates via Soroban event polling

import { useEffect, useRef, useState } from 'react';
import type { MockRound } from '../types';
import { useState } from "react";


import CountdownTimer from './CountdownTimer';
import PanelHeader from './PanelHeader';
import { formatVXLM, formatPercent } from '../lib/utils';
import { TRANSITION } from '../utils/motion';

const ASSET_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
XLM: '✦',
};

interface RoundCardProps {
round: MockRound;
onSubmitPrediction: (round: MockRound) => void;
Expand All @@ -35,60 +36,70 @@ function poolSize(round: MockRound): number {
}

export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) {

const [endTime] = useState(() => Date.now() + round.closesInSeconds * 1000);
const [endTime, setEndTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000));
const total = poolSize(round);

const upRatio = round.mode === 'updown' && total > 0 ? (round.poolUp ?? 0) / total : 0;
const upPct = Math.round(upRatio * 100);
const downPct = round.mode === 'updown' ? 100 - upPct : 0;
const [endTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000));

const statusMeta = getStatusMeta(round, round.closesInSeconds);
const prevStatus = useRef(statusMeta.label);
const [statusAnnouncement, setStatusAnnouncement] = useState('');
const [endTime, setEndTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000));

useEffect(() => {
const timer = setTimeout(() => {
const timer = window.setTimeout(() => {
setEndTime(new Date(Date.now() + round.closesInSeconds * 1000));
}, 0);
return () => clearTimeout(timer);
return () => window.clearTimeout(timer);
}, [round.closesInSeconds]);

useEffect(() => {
const timer = setTimeout(() => {
const timer = window.setTimeout(() => {
if (round.closesInSeconds <= 0) {
setStatusAnnouncement('Round has ended');
} else if (prevStatus.current !== statusMeta.label) {
setStatusAnnouncement(`Round status: ${statusMeta.label}`);
prevStatus.current = statusMeta.label;
}
}, 0);
return () => clearTimeout(timer);
}, [statusMeta.label, round.closesInSeconds]);
return () => window.clearTimeout(timer);
}, [round.closesInSeconds, statusMeta.label]);

return (
<article
className={`glass-card flex min-w-0 flex-col gap-4 rounded-2xl p-4 sm:p-5 ${TRANSITION}`}
className={`glass-card flex min-w-0 flex-col gap-4 rounded-2xl p-4 transition-all duration-300 sm:p-5 ${TRANSITION}`}
data-testid="round-card"
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusAnnouncement}
</div>

<header className="flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 items-center gap-3">
<span
className={`self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide sm:self-auto ${
round.mode === "updown"
? "bg-[#2C4BFD]/15 text-[#BEC7FE]"
: "bg-cyan-500/15 text-cyan-300"
}`}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#2C4BFD]/15 text-lg font-bold text-[#BEC7FE]"
aria-hidden
>
{round.mode === "updown" ? "UP/DOWN" : "PRECISION"}
{ASSET_ICONS[round.asset]}
</span>
}
/>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-bold text-white">{round.asset}/USD</h3>
<p className="truncate text-xs text-gray-500">
Reference ${round.startPrice.toLocaleString()}
</p>
</div>
</div>

<span
className={`self-start rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide sm:self-auto ${
round.mode === 'updown'
? 'bg-[#2C4BFD]/15 text-[#BEC7FE]'
: 'bg-cyan-500/15 text-cyan-300'
}`}
>
{round.mode === 'updown' ? 'UP/DOWN' : 'PRECISION'}
</span>
</header>

<div
className="flex min-w-0 flex-col gap-2 text-sm text-gray-400 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-3"
Expand Down Expand Up @@ -142,7 +153,7 @@ export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps)
type="button"
disabled={round.closesInSeconds <= 0}
onClick={() => onSubmitPrediction(round)}
className="btn-primary mt-2 flex min-h-[44px] w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed"
className="btn-primary mt-2 flex min-h-[44px] w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-bold disabled:cursor-not-allowed disabled:opacity-50"
data-testid="round-card-submit"
>
Submit Prediction
Expand Down
1 change: 1 addition & 0 deletions src/components/StatsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { claim_winnings } from '../lib/xelma-contract';
import { toast } from 'sonner';
import { formatVXLM } from '../lib/utils';
import RankProgressBar from './RankProgressBar';
import PanelHeader from './PanelHeader';

interface StatsCardProps {
stats: MockUserStats;
Expand Down
37 changes: 12 additions & 25 deletions src/hooks/__tests__/useRoundCountdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useRoundCountdown } from '../../hooks/useRoundCountdown';

describe('useRoundCountdown Hook', () => {
Expand All @@ -9,55 +9,42 @@ describe('useRoundCountdown Hook', () => {
});

afterEach(() => {
import { renderHook, act } from "@testing-library/react";
import { describe, test, expect, beforeAll, afterAll, vi } from "vitest";
import { useRoundCountdown } from '../../hooks/useRoundCountdown';

// Helper to advance timers safely
function advance(ms: number) {
act(() => {
vi.advanceTimersByTime(ms);
});
}

describe('useRoundCountdown Hook', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-27T12:00:00Z')); // fixed now
});

afterAll(() => {
vi.useRealTimers();
});

it('Expired context returns isExpired true and 00:00', () => {
it('returns 00:00 and expires for past times', () => {
const past = new Date('2026-06-27T11:00:00Z');
const { result } = renderHook(() => useRoundCountdown(past));

expect(result.current.isExpired).toBe(true);
expect(result.current.formattedTime).toBe('00:00');
expect(result.current.timeLeftMs).toBe(0);
});

it('Sub‑minute context shows mm:ss format', () => {
it('shows mm:ss formatting for sub-minute countdowns', () => {
const future = new Date(Date.now() + 30 * 1000);
const { result } = renderHook(() => useRoundCountdown(future));

expect(result.current.isExpired).toBe(false);
expect(result.current.formattedTime).toBe('0:30');
expect(result.current.formattedTime).toBe('00:30');

act(() => {
vi.advanceTimersByTime(10 * 1000);
});
expect(result.current.formattedTime).toBe('0:20');

expect(result.current.formattedTime).toBe('00:20');
});

it('Multi‑hour context formats HH:MM:SS', () => {
it('formats multi-hour countdowns as HH:MM:SS', () => {
const future = new Date(Date.now() + (1 * 3600 + 2 * 60 + 3) * 1000);
const { result } = renderHook(() => useRoundCountdown(future));

expect(result.current.formattedTime).toBe('01:02:03');

act(() => {
vi.advanceTimersByTime(62 * 1000);
});

expect(result.current.formattedTime).toBe('01:01:01');
});
});
4 changes: 2 additions & 2 deletions src/hooks/useRoundCountdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export function useRoundCountdown(
if (h > 0) {
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
// When less than an hour, omit hour component and do not pad minutes.
return `${m}:${pad(s)}`;
// When less than an hour, show a zero-padded minute value.
return `${pad(m)}:${pad(s)}`;
};

useEffect(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ const Dashboard = () => {

{!isLoading && !isRoundActive && (
<EmptyState
title="No active round"
description="Check back soon for the next prediction round."
title="No Active Rounds"
description="Learn how the game works or refresh to check for new rounds."
action={
<button
type="button"
Expand Down
Loading