Skip to content
Open
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
50 changes: 20 additions & 30 deletions src/components/DutchAuctionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,50 @@

import { useState, useEffect } from 'react';
import React from 'react';
import type { DutchAuction } from '../types/dutchAuction';
import { COLOR, fmt } from '../utils/tokens';
import { PendingButton } from './PendingButton';
import { useLedgerTime } from '../hooks/useLedgerTime';

interface DutchAuctionCardProps {
auction: DutchAuction;
onPurchase?: (auctionId: string, price: number) => void;
}

const calculateCurrentPrice = (auction: DutchAuction): number => {
const now = Date.now();
const calculateCurrentPrice = (auction: DutchAuction, now: number): number => {
const start = new Date(auction.startTime).getTime();
const end = new Date(auction.endTime).getTime();

if (now < start) return auction.startPrice;
if (now > end) return auction.floorPrice;

const elapsed = now - start;
const total = end - start;
const progress = elapsed / total;

return auction.startPrice - progress * (auction.startPrice - auction.floorPrice);
};

const formatTimeLeft = (endTime: string): string => {
const now = Date.now();
const formatTimeLeft = (endTime: string, now: number): string => {
const end = new Date(endTime).getTime();
const diff = end - now;

if (diff <= 0) return 'Ended';

const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);

const h = hours;
const m = minutes % 60;
const s = seconds % 60;

return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};

export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onPurchase }) => {
const [currentPrice, setCurrentPrice] = useState(calculateCurrentPrice(auction));
const [timeLeft, setTimeLeft] = useState(formatTimeLeft(auction.endTime));

useEffect(() => {
if (auction.status !== 'Active') return;
const { now, isSynced } = useLedgerTime();

const interval = setInterval(() => {
setCurrentPrice(calculateCurrentPrice(auction));
setTimeLeft(formatTimeLeft(auction.endTime));
}, 1000);

return () => clearInterval(interval);
}, [auction]);
const currentPrice = calculateCurrentPrice(auction, now);
const timeLeft = formatTimeLeft(auction.endTime, now);

return (
<div className="card" style={{ padding: '1rem', marginBottom: '1rem' }}>
Expand All @@ -69,6 +58,8 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
borderRadius: '8px',
objectFit: 'cover',
border: `1px solid ${COLOR.border}`,
opacity: isSynced ? 1 : 0.7,
transition: 'opacity 0.3s ease'
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
Expand All @@ -87,12 +78,12 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 500,
background: auction.status === 'Active'
? 'rgba(63,185,80,0.16)'
background: auction.status === 'Active'
? 'rgba(63,185,80,0.16)'
: auction.status === 'Completed'
? 'rgba(88,166,255,0.16)'
: 'rgba(248,81,73,0.16)',
color: auction.status === 'Active'
color: auction.status === 'Active'
? '#8ee99d'
: auction.status === 'Completed'
? '#58a6ff'
Expand Down Expand Up @@ -127,7 +118,7 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
{auction.status === 'Active' && (
<div>
<p style={{ margin: 0, color: COLOR.muted, fontSize: '0.75rem' }}>
Time Left
{isSynced ? 'Time Left' : 'Time Left (Syncing...)'}
</p>
<p style={{ margin: 0, color: COLOR.warning, fontSize: '1rem', fontWeight: 600 }}>
{timeLeft}
Expand Down Expand Up @@ -161,4 +152,3 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
</div>
);
};

59 changes: 59 additions & 0 deletions src/hooks/useLedgerTime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useLedgerTime } from './useLedgerTime';
import { useWallet } from '../context/WalletContext';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

vi.mock('../context/WalletContext', () => ({
useWallet: vi.fn(),
}));

global.fetch = vi.fn();

describe('useLedgerTime', () => {
beforeEach(() => {
vi.resetAllMocks();
(useWallet as any).mockReturnValue({ wallet: { network: 'public' } });
});

afterEach(() => {
vi.restoreAllMocks();
});

it('syncs with ledger time and calculates offset', async () => {
const mockLedgerTime = new Date('2026-08-30T10:00:00Z').getTime();
const localLaggingTime = mockLedgerTime - 300000; // 5 minutes behind

vi.spyOn(Date, 'now').mockReturnValue(localLaggingTime);

(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ history_latest_ledger_closed_at: '2026-08-30T10:00:00Z' }),
});

const { result } = renderHook(() => useLedgerTime());

expect(result.current.isSynced).toBe(false);

await waitFor(() => {
expect(result.current.isSynced).toBe(true);
});

// The hook's `now` output should exactly equal the network ledger time,
// entirely ignoring the lagging local clock.
expect(result.current.now).toBe(mockLedgerTime);
});

it('degrades gracefully to local time if fetch fails', async () => {
const localTime = 1700000000000;
vi.spyOn(Date, 'now').mockReturnValue(localTime);
(global.fetch as any).mockRejectedValue(new Error('Horizon offline'));

const { result } = renderHook(() => useLedgerTime());

// We wait a microtask tick for the failed promise to resolve and catch
await new Promise(process.nextTick);

expect(result.current.isSynced).toBe(false);
expect(result.current.now).toBe(localTime);
});
});
58 changes: 58 additions & 0 deletions src/hooks/useLedgerTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import { useWallet } from '../context/WalletContext';

export const useLedgerTime = () => {
const { wallet } = useWallet();
const [offset, setOffset] = useState<number>(0);
const [isSynced, setIsSynced] = useState<boolean>(false);
const [now, setNow] = useState<number>(Date.now());

useEffect(() => {
let mounted = true;
let timeoutId: ReturnType<typeof setTimeout>;

const syncWithLedger = async () => {
try {
const networkUrl = wallet?.network === 'testnet'
? 'https://horizon-testnet.stellar.org'
: 'https://horizon.stellar.org';

const response = await fetch(networkUrl);
if (!response.ok) throw new Error('Horizon fetch failed');

const data = await response.json();
const ledgerTime = new Date(data.history_latest_ledger_closed_at).getTime();

if (mounted) {
setOffset(ledgerTime - Date.now());
setIsSynced(true);
}
} catch (err) {
console.warn('Failed to sync ledger time, falling back to local clock.');
}

if (mounted) {
timeoutId = setTimeout(syncWithLedger, 30000);
}
};

syncWithLedger();

return () => {
mounted = false;
clearTimeout(timeoutId);
};
}, [wallet?.network]);

useEffect(() => {
// Instantly apply the offset the moment it changes
setNow(Date.now() + offset);

const ticker = setInterval(() => {
setNow(Date.now() + offset);
}, 1000);
return () => clearInterval(ticker);
}, [offset]);

return { now, isSynced };
};