Skip to content

Commit 8e882ae

Browse files
authored
Merge pull request #473 from Agencybuilds/feature/expiry-countdown
feat(frontend): add expiry countdown to delegations list
2 parents 33f4dd2 + 1a7c130 commit 8e882ae

4 files changed

Lines changed: 206 additions & 0 deletions

File tree

apps/frontend/app/page.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"use client";
12
import type { Metadata } from "next";
23
import { HomeContent } from "../components/HomeContent";
34

@@ -37,6 +38,51 @@ import { WalletConnectButton } from "../components/wallet/WalletConnectButton";
3738
import { useDelegations } from "../hooks/useDelegations";
3839
import { useOrders } from "../hooks/useOrders";
3940

41+
import { Button, Card } from "@delego/ui";
42+
import { useDelegations } from "../hooks/useDelegations";
43+
import { ExpiryCountdown } from "../components/delegations/ExpiryCountdown";
4044
export default function HomePage() {
45+
const { delegations, loading } = useDelegations();
46+
47+
return (
48+
<main className="container">
49+
<header className="header">
50+
<h1>Delego</h1>
51+
<p>AI commerce with approval and spending controls</p>
52+
</header>
53+
54+
<section className="grid">
55+
<Card title="Delegations">
56+
<p>Grant AI agents scoped shopping authority.</p>
57+
<div className="flex flex-col gap-2 mt-4 mb-4">
58+
{loading ? (
59+
<p>Loading delegations...</p>
60+
) : delegations.length === 0 ? (
61+
<p>No active delegations.</p>
62+
) : (
63+
delegations.map(d => (
64+
<div key={d.id} className="flex justify-between items-center p-2 border rounded">
65+
<span>Agent: {d.agentId}</span>
66+
<ExpiryCountdown expiresAt={d.policy.expiresAt} />
67+
</div>
68+
))
69+
)}
70+
</div>
71+
<Button variant="primary">Create Delegation</Button>
72+
</Card>
73+
74+
<Card title="Orders">
75+
<p>Track purchases initiated by your agents.</p>
76+
{/* TODO: List recent orders */}
77+
</Card>
78+
79+
<Card title="Wallet">
80+
<p>Connect your Stellar wallet.</p>
81+
{/* TODO: Wallet connection via Soroban permissions */}
82+
<Button variant="secondary">Connect Wallet</Button>
83+
</Card>
84+
</section>
85+
</main>
86+
);
4187
return <HomeContent />;
4288
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen, act } from "@testing-library/react";
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { ExpiryCountdown } from "./ExpiryCountdown";
4+
5+
describe("ExpiryCountdown", () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
});
13+
14+
it("returns null if no expiresAt is provided", () => {
15+
const { container } = render(<ExpiryCountdown expiresAt={null} />);
16+
expect(container.firstChild).toBeNull();
17+
});
18+
19+
it("displays correct time remaining", () => {
20+
const now = new Date("2026-07-25T10:00:00Z").getTime();
21+
vi.setSystemTime(now);
22+
23+
// 1 hour, 30 minutes, 15 seconds from now
24+
const expiresAt = new Date(now + 1000 * (15 + 60 * 30 + 60 * 60 * 1)).toISOString();
25+
26+
render(<ExpiryCountdown expiresAt={expiresAt} />);
27+
28+
const countdown = screen.getByTestId("countdown-timer");
29+
expect(countdown.textContent).toContain("1h 30m 15s remaining");
30+
});
31+
32+
it("updates countdown over time", () => {
33+
const now = new Date("2026-07-25T10:00:00Z").getTime();
34+
vi.setSystemTime(now);
35+
36+
const expiresAt = new Date(now + 10000).toISOString(); // 10 seconds
37+
38+
render(<ExpiryCountdown expiresAt={expiresAt} />);
39+
40+
expect(screen.getByTestId("countdown-timer").textContent).toContain("10s remaining");
41+
42+
act(() => {
43+
vi.advanceTimersByTime(2000);
44+
});
45+
46+
expect(screen.getByTestId("countdown-timer").textContent).toContain("8s remaining");
47+
});
48+
49+
it("shows Expired badge when time is up", () => {
50+
const now = new Date("2026-07-25T10:00:00Z").getTime();
51+
vi.setSystemTime(now);
52+
53+
const expiresAt = new Date(now - 1000).toISOString(); // 1 second ago
54+
55+
render(<ExpiryCountdown expiresAt={expiresAt} />);
56+
57+
const badge = screen.getByTestId("expired-badge");
58+
expect(badge).toBeDefined();
59+
expect(badge.textContent).toBe("Expired");
60+
});
61+
62+
it("changes color based on time remaining", () => {
63+
const now = new Date("2026-07-25T10:00:00Z").getTime();
64+
vi.setSystemTime(now);
65+
66+
// Red: < 5 minutes
67+
const expiresAtRed = new Date(now + 1000 * 60 * 4).toISOString();
68+
const { rerender } = render(<ExpiryCountdown expiresAt={expiresAtRed} />);
69+
expect(screen.getByTestId("countdown-timer").className).toContain("text-red-500");
70+
71+
// Orange: < 1 hour
72+
const expiresAtOrange = new Date(now + 1000 * 60 * 30).toISOString();
73+
rerender(<ExpiryCountdown expiresAt={expiresAtOrange} />);
74+
expect(screen.getByTestId("countdown-timer").className).toContain("text-orange-500");
75+
76+
// Yellow: < 24 hours
77+
const expiresAtYellow = new Date(now + 1000 * 60 * 60 * 12).toISOString();
78+
rerender(<ExpiryCountdown expiresAt={expiresAtYellow} />);
79+
expect(screen.getByTestId("countdown-timer").className).toContain("text-yellow-500");
80+
81+
// Green: >= 24 hours
82+
const expiresAtGreen = new Date(now + 1000 * 60 * 60 * 48).toISOString();
83+
rerender(<ExpiryCountdown expiresAt={expiresAtGreen} />);
84+
expect(screen.getByTestId("countdown-timer").className).toContain("text-green-500");
85+
});
86+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
5+
export interface ExpiryCountdownProps {
6+
expiresAt: string | Date | number | null;
7+
}
8+
9+
export function ExpiryCountdown({ expiresAt }: ExpiryCountdownProps) {
10+
const [timeLeft, setTimeLeft] = useState<number>(0);
11+
const [isExpired, setIsExpired] = useState<boolean>(false);
12+
13+
useEffect(() => {
14+
if (!expiresAt) {
15+
return;
16+
}
17+
18+
const target = new Date(expiresAt).getTime();
19+
20+
const updateCountdown = () => {
21+
const now = Date.now();
22+
const diff = target - now;
23+
if (diff <= 0) {
24+
setIsExpired(true);
25+
setTimeLeft(0);
26+
} else {
27+
setIsExpired(false);
28+
setTimeLeft(diff);
29+
}
30+
};
31+
32+
updateCountdown();
33+
const interval = setInterval(updateCountdown, 1000);
34+
return () => clearInterval(interval);
35+
}, [expiresAt]);
36+
37+
if (!expiresAt) {
38+
return null;
39+
}
40+
41+
if (isExpired) {
42+
return <span className="badge badge-expired text-red-500 font-bold" data-testid="expired-badge">Expired</span>;
43+
}
44+
45+
const days = Math.floor(timeLeft / (1000 * 60 * 60 * 24));
46+
const hours = Math.floor((timeLeft / (1000 * 60 * 60)) % 24);
47+
const minutes = Math.floor((timeLeft / 1000 / 60) % 60);
48+
const seconds = Math.floor((timeLeft / 1000) % 60);
49+
50+
let colorClass = "text-green-500";
51+
// Warning colors
52+
if (days === 0 && hours < 24) {
53+
colorClass = "text-yellow-500";
54+
}
55+
if (days === 0 && hours === 0 && minutes < 60) {
56+
colorClass = "text-orange-500";
57+
}
58+
if (days === 0 && hours === 0 && minutes < 5) {
59+
colorClass = "text-red-500";
60+
}
61+
62+
const parts: string[] = [];
63+
if (days > 0) parts.push(`${days}d`);
64+
if (hours > 0) parts.push(`${hours}h`);
65+
if (minutes > 0) parts.push(`${minutes}m`);
66+
parts.push(`${seconds}s`);
67+
68+
return (
69+
<span className={`countdown ${colorClass}`} data-testid="countdown-timer">
70+
{parts.join(" ")} remaining
71+
</span>
72+
);
73+
}

packages/types/src/delegation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface Delegation {
2323
userId: string;
2424
agentId: string;
2525
status: DelegationStatus;
26+
expires_at_ledger?: number;
2627
policy: SpendingPolicy;
2728
createdAt: Date;
2829
updatedAt: Date;

0 commit comments

Comments
 (0)