diff --git a/src/components/RoundCard.tsx b/src/components/RoundCard.tsx index b63518e..900bebd 100644 --- a/src/components/RoundCard.tsx +++ b/src/components/RoundCard.tsx @@ -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 = { BTC: '₿', @@ -43,6 +47,10 @@ const RoundCard = forwardRef(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); @@ -71,6 +79,21 @@ const RoundCard = forwardRef(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 (
(function RoundCard( data-highlighted={isHighlighted ? 'true' : 'false'} >
- {statusAnnouncement} + {[statusAnnouncement, urgencyAnnouncement].filter(Boolean).join(' ')}
@@ -123,6 +146,15 @@ const RoundCard = forwardRef(function RoundCard( {getStatusMeta(round, round.closesInSeconds).label} + {isUrgent && ( + + + )}
Resolves in diff --git a/src/components/__tests__/RoundCard.test.tsx b/src/components/__tests__/RoundCard.test.tsx index 1319851..5218038 100644 --- a/src/components/__tests__/RoundCard.test.tsx +++ b/src/components/__tests__/RoundCard.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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', () => { diff --git a/src/index.css b/src/index.css index bb7950f..34ca4f8 100644 --- a/src/index.css +++ b/src/index.css @@ -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; @@ -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); @@ -177,7 +197,8 @@ body { } .status-dot-live, - .status-dot-red { + .status-dot-red, + .status-dot-urgent { animation: none; } diff --git a/src/stories/RoundCard.stories.tsx b/src/stories/RoundCard.stories.tsx index 5800a99..51e9f07 100644 --- a/src/stories/RoundCard.stories.tsx +++ b/src/stories/RoundCard.stories.tsx @@ -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 = () => ; export const Precision = () => ; export const NewRound = () => ; export const Closed = () => ; +export const Urgent = () => ;