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
34 changes: 33 additions & 1 deletion src/components/RoundCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import CountdownTimer from './CountdownTimer';
import { formatVXLM, formatPercent } from '../lib/utils';
import { TRANSITION } from '../utils/motion';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useRoundCountdown } from '../hooks/useRoundCountdown';

const URGENCY_THRESHOLD_SECONDS = 30;
const URGENCY_THRESHOLD_MS = URGENCY_THRESHOLD_SECONDS * 1000;

const ASSET_ICONS: Record<string, string> = {
BTC: '₿',
Expand Down Expand Up @@ -43,6 +47,10 @@ const RoundCard = forwardRef<HTMLElement, RoundCardProps>(function RoundCard(
) {
const { reduced } = useReducedMotion();
const [endTime, setEndTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000));
// Live ticking countdown so the urgency state (secondsLeft < 30) updates
// between server round refreshes, matching the displayed countdown timer.
const { isExpired, timeLeftMs } = useRoundCountdown(endTime);
const isUrgent = !isExpired && timeLeftMs < URGENCY_THRESHOLD_MS;
const total = poolSize(round);
const upRatio = round.mode === 'updown' && total > 0 ? (round.poolUp ?? 0) / total : 0;
const upPct = Math.round(upRatio * 100);
Expand Down Expand Up @@ -71,6 +79,21 @@ const RoundCard = forwardRef<HTMLElement, RoundCardProps>(function RoundCard(
return () => window.clearTimeout(timer);
}, [round.closesInSeconds, statusMeta.label]);

const prevUrgent = useRef(isUrgent);
const [urgencyAnnouncement, setUrgencyAnnouncement] = useState('');

// Announce the urgency state transition (under 30s) politely, distinct from
// the CLOSING SOON status announcement above.
useEffect(() => {
const timer = window.setTimeout(() => {
if (prevUrgent.current !== isUrgent) {
prevUrgent.current = isUrgent;
setUrgencyAnnouncement(isUrgent ? 'Round closing in under 30 seconds' : '');
}
}, 0);
return () => window.clearTimeout(timer);
}, [isUrgent]);

return (
<article
ref={ref}
Expand All @@ -81,7 +104,7 @@ const RoundCard = forwardRef<HTMLElement, RoundCardProps>(function RoundCard(
data-highlighted={isHighlighted ? 'true' : 'false'}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusAnnouncement}
{[statusAnnouncement, urgencyAnnouncement].filter(Boolean).join(' ')}
</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">
Expand Down Expand Up @@ -123,6 +146,15 @@ const RoundCard = forwardRef<HTMLElement, RoundCardProps>(function RoundCard(
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
{getStatusMeta(round, round.closesInSeconds).label}
</span>
{isUrgent && (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-rose-500/40 bg-rose-500/10 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wider text-rose-300"
data-testid="round-card-urgency"
>
<span className="status-dot status-dot-urgent" aria-hidden="true" />
Under 30s
</span>
)}
</div>
<div className="flex items-center gap-2 whitespace-nowrap text-sm text-gray-400">
<span>Resolves in</span>
Expand Down
54 changes: 54 additions & 0 deletions src/components/__tests__/RoundCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,60 @@ describe('RoundCard Component', () => {
expect(onSubmitPredictionMock).not.toHaveBeenCalled();
});

// Issue #414 — Round-closing urgency state when under 30 seconds
describe('urgency state (#414)', () => {
it('renders the urgency indicator when secondsLeft < 30', () => {
const urgentRound: MockRound = {
...defaultRound,
closesInSeconds: 25,
};
render(<RoundCard round={urgentRound} onSubmitPrediction={vi.fn()} />);

const pill = screen.getByTestId('round-card-urgency');
expect(pill).toBeInTheDocument();
expect(screen.getByText('Under 30s')).toBeInTheDocument();
});

it('does not render the urgency indicator when secondsLeft >= 30', () => {
render(<RoundCard round={defaultRound} onSubmitPrediction={vi.fn()} />);

expect(screen.queryByTestId('round-card-urgency')).not.toBeInTheDocument();
});

it('does not render the urgency indicator when the round has expired', () => {
const expiredRound: MockRound = {
...defaultRound,
closesInSeconds: 0,
};
render(<RoundCard round={expiredRound} onSubmitPrediction={vi.fn()} />);

expect(screen.queryByTestId('round-card-urgency')).not.toBeInTheDocument();
});

it('announces the urgency transition politely when crossing the 30-second threshold', () => {
vi.useFakeTimers();
// Exactly 30s is not urgent; ticking below the threshold triggers it.
const boundaryRound: MockRound = {
...defaultRound,
closesInSeconds: 30,
};
render(<RoundCard round={boundaryRound} onSubmitPrediction={vi.fn()} />);

expect(screen.queryByText('Round closing in under 30 seconds')).not.toBeInTheDocument();

// Tick past the 30-second threshold, then flush the announcement timer.
act(() => {
vi.advanceTimersByTime(2000);
});
act(() => {
vi.advanceTimersByTime(0);
});

expect(screen.getByText('Round closing in under 30 seconds')).toBeInTheDocument();
vi.useRealTimers();
});
});

// Issue #175 — Improve RoundCard touch targets and mobile card layout
describe('Mobile layout & touch targets (#175)', () => {
it('submit button enforces a minimum 44px tap target height', () => {
Expand Down
23 changes: 22 additions & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ body {
box-shadow: 0 0 8px #fbbf24;
}

/* Urgent (round closing in under 30 seconds) — distinct from the yellow
CLOSING SOON dot, used by RoundCard's non-blocking urgency pill. */
.status-dot-urgent {
background: #fb7185;
box-shadow: 0 0 8px #fb7185;
animation: pulse-urgent 0.9s ease-in-out infinite;
}

.status-dot-green {
background: #06b6d4;
box-shadow: 0 0 8px #06b6d4;
Expand All @@ -154,6 +162,18 @@ body {
}
}

@keyframes pulse-urgent {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.4);
}
}

.navbar {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
Expand All @@ -177,7 +197,8 @@ body {
}

.status-dot-live,
.status-dot-red {
.status-dot-red,
.status-dot-urgent {
animation: none;
}

Expand Down
12 changes: 12 additions & 0 deletions src/stories/RoundCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,21 @@ const closedRound: MockRound = {
closesInSeconds: 0,
};

const urgentRound: MockRound = {
id: 5,
asset: 'BTC',
mode: 'updown',
status: 'live',
startPrice: 67420,
poolUp: 2800,
poolDown: 1400,
closesInSeconds: 25,
};

const noop = () => {};

export const UpDown = () => <RoundCard round={updownRound} onSubmitPrediction={noop} />;
export const Precision = () => <RoundCard round={precisionRound} onSubmitPrediction={noop} />;
export const NewRound = () => <RoundCard round={newRound} onSubmitPrediction={noop} />;
export const Closed = () => <RoundCard round={closedRound} onSubmitPrediction={noop} />;
export const Urgent = () => <RoundCard round={urgentRound} onSubmitPrediction={noop} />;