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
154 changes: 125 additions & 29 deletions client/src/components/dashboard/ApyDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ type SortField = "apy" | "tvl" | "risk" | "protocol";
type SortDirection = "asc" | "desc";
type ViewMode = "grid" | "table";

const SORT_LABELS: Record<SortField, string> = {
apy: "APY",
tvl: "TVL",
risk: "risk",
protocol: "protocol",
};

interface ApiApyEntry {
protocol?: unknown;
asset?: unknown;
Expand Down Expand Up @@ -165,6 +172,27 @@ function getErrorMessage(error: unknown): string {
return "Unable to fetch live APY data right now";
}

function getSortButtonLabel(
field: SortField,
activeField: SortField,
direction: SortDirection,
): string {
const label = SORT_LABELS[field];
if (field !== activeField) return `Sort by ${label}`;
return `Sort by ${label}, currently ${
direction === "asc" ? "ascending" : "descending"
}`;
}

function getAriaSort(
field: SortField,
activeField: SortField,
direction: SortDirection,
): "ascending" | "descending" | "none" {
if (field !== activeField) return "none";
return direction === "asc" ? "ascending" : "descending";
}

// ── Skeleton Components ─────────────────────────────────────────────────

function SkeletonCard() {
Expand Down Expand Up @@ -352,6 +380,7 @@ export default function ApyDashboard() {
const SortIcon = ({ field }: { field: SortField }) => (
<ChevronDown
size={14}
aria-hidden="true"
className={`inline-block ml-1 transition-transform ${
sortField === field ? "opacity-100" : "opacity-0 group-hover:opacity-50"
} ${sortField === field && sortDirection === "asc" ? "rotate-180" : ""}`}
Expand Down Expand Up @@ -577,6 +606,7 @@ export default function ApyDashboard() {
<div className="glass-card flex overflow-hidden p-1 gap-1">
<button
onClick={() => setViewMode("grid")}
aria-pressed={viewMode === "grid"}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
viewMode === "grid"
? "bg-[#6C5DD3] text-white"
Expand All @@ -588,6 +618,7 @@ export default function ApyDashboard() {
</button>
<button
onClick={() => setViewMode("table")}
aria-pressed={viewMode === "table"}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
viewMode === "table"
? "bg-[#6C5DD3] text-white"
Expand Down Expand Up @@ -644,35 +675,43 @@ export default function ApyDashboard() {
{entry.category}
</p>
</div>
<div
<button
type="button"
className="group/risk relative flex cursor-help outline-none"
tabIndex={0}
aria-describedby={`risk-tip-grid-${entry.protocol}-${entry.asset}`}
aria-label={`${entry.protocol} ${entry.asset} risk: ${entry.risk}. ${risk.explanation}`}
>
<span
className={`${risk.bg} ${risk.color} ${risk.border} border px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-wider flex items-center gap-1`}
>
{entry.risk} <Info size={10} />
{entry.risk} <Info size={10} aria-hidden="true" />
</span>
<div
<span
id={`risk-tip-grid-${entry.protocol}-${entry.asset}`}
role="tooltip"
className="absolute hidden group-hover/risk:block group-focus-within/risk:block bottom-full mb-2 right-0 w-48 p-2 bg-[#1A1A24] border border-white/10 rounded-lg text-xs leading-relaxed text-gray-300 shadow-xl z-10 transition-opacity"
>
{risk.explanation}
</div>
</div>
</span>
</button>
</div>

{/* Freshness Indicator */}
<div className="flex items-center gap-1.5 mb-3 text-[10px] font-medium uppercase tracking-wider">
{isStale ? (
<span className="text-red-400 flex items-center gap-1 bg-red-400/10 px-2 py-0.5 rounded-full">
<Clock size={10} /> Stale Data ({diffMins}m old)
<span
className="text-red-400 flex items-center gap-1 bg-red-400/10 px-2 py-0.5 rounded-full"
aria-label={`Stale data, ${diffMins} minutes old`}
>
<Clock size={10} aria-hidden="true" /> Stale Data
({diffMins}m old)
</span>
) : (
<span className="text-gray-500 flex items-center gap-1">
<Clock size={10} /> Updated just now (
<span
className="text-gray-500 flex items-center gap-1"
aria-label={`Updated just now, ${Math.round((entry.freshnessConfidence ?? 1) * 100)} percent confidence`}
>
<Clock size={10} aria-hidden="true" /> Updated just now (
{Math.round((entry.freshnessConfidence ?? 1) * 100)}
% confidence)
</span>
Expand Down Expand Up @@ -796,30 +835,82 @@ export default function ApyDashboard() {
<thead>
<tr className="bg-[rgba(255,255,255,0.02)] text-gray-400 text-xs uppercase tracking-wider">
<th
className="px-6 py-4 font-semibold cursor-pointer group select-none"
onClick={() => handleSort("protocol")}
className="px-6 py-4 font-semibold"
aria-sort={getAriaSort(
"protocol",
sortField,
sortDirection,
)}
>
Protocol <SortIcon field="protocol" />
<button
type="button"
onClick={() => handleSort("protocol")}
aria-pressed={sortField === "protocol"}
aria-label={getSortButtonLabel(
"protocol",
sortField,
sortDirection,
)}
className="group inline-flex items-center uppercase tracking-wider text-left"
>
Protocol <SortIcon field="protocol" />
</button>
</th>
<th className="px-6 py-4 font-semibold">Asset</th>
<th
className="px-6 py-4 font-semibold cursor-pointer group select-none"
onClick={() => handleSort("apy")}
className="px-6 py-4 font-semibold"
aria-sort={getAriaSort("apy", sortField, sortDirection)}
>
APY <SortIcon field="apy" />
<button
type="button"
onClick={() => handleSort("apy")}
aria-pressed={sortField === "apy"}
aria-label={getSortButtonLabel(
"apy",
sortField,
sortDirection,
)}
className="group inline-flex items-center uppercase tracking-wider text-left"
>
APY <SortIcon field="apy" />
</button>
</th>
<th className="px-6 py-4 font-semibold">24h Change</th>
<th
className="px-6 py-4 font-semibold cursor-pointer group select-none"
onClick={() => handleSort("tvl")}
className="px-6 py-4 font-semibold"
aria-sort={getAriaSort("tvl", sortField, sortDirection)}
>
TVL <SortIcon field="tvl" />
<button
type="button"
onClick={() => handleSort("tvl")}
aria-pressed={sortField === "tvl"}
aria-label={getSortButtonLabel(
"tvl",
sortField,
sortDirection,
)}
className="group inline-flex items-center uppercase tracking-wider text-left"
>
TVL <SortIcon field="tvl" />
</button>
</th>
<th
className="px-6 py-4 font-semibold cursor-pointer group select-none"
onClick={() => handleSort("risk")}
className="px-6 py-4 font-semibold"
aria-sort={getAriaSort("risk", sortField, sortDirection)}
>
Risk <SortIcon field="risk" />
<button
type="button"
onClick={() => handleSort("risk")}
aria-pressed={sortField === "risk"}
aria-label={getSortButtonLabel(
"risk",
sortField,
sortDirection,
)}
className="group inline-flex items-center uppercase tracking-wider text-left"
>
Risk <SortIcon field="risk" />
</button>
</th>
<th className="px-6 py-4 font-semibold text-right">Action</th>
</tr>
Expand Down Expand Up @@ -868,7 +959,10 @@ export default function ApyDashboard() {
{entry.category}
</p>
{isStale && (
<span className="text-[9px] text-red-400 bg-red-400/10 px-1.5 py-px rounded uppercase">
<span
className="text-[9px] text-red-400 bg-red-400/10 px-1.5 py-px rounded uppercase"
aria-label={`Stale data, ${diffMins} minutes old`}
>
Stale
</span>
)}
Expand Down Expand Up @@ -906,24 +1000,26 @@ export default function ApyDashboard() {
{formatTvl(entry.tvl)}
</td>
<td className="px-6 py-5">
<div
<button
type="button"
className="group/risk relative inline-flex cursor-help outline-none"
tabIndex={0}
aria-describedby={`risk-tip-table-${entry.protocol}-${entry.asset}`}
aria-label={`${entry.protocol} ${entry.asset} risk: ${entry.risk}. ${risk.explanation}`}
>
<span
className={`${risk.bg} ${risk.color} ${risk.border} border px-2.5 py-1.5 rounded text-xs font-bold uppercase tracking-wider flex items-center gap-1`}
>
{entry.risk} <Info size={12} />
{entry.risk}{" "}
<Info size={12} aria-hidden="true" />
</span>
<div
<span
id={`risk-tip-table-${entry.protocol}-${entry.asset}`}
role="tooltip"
className="absolute hidden group-hover/risk:block group-focus-within/risk:block bottom-full mb-2 left-1/2 -translate-x-1/2 w-48 p-2 bg-[#1A1A24] border border-white/10 rounded-lg text-xs leading-relaxed text-gray-300 shadow-xl z-10 transition-opacity"
>
{risk.explanation}
</div>
</div>
</span>
</button>
</td>
<td className="px-6 py-5 text-right">
{entry.capitalEfficiency && (
Expand Down
49 changes: 49 additions & 0 deletions client/src/components/dashboard/__tests__/ApyDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,53 @@ describe('ApyDashboard states', () => {
expect(screen.getByText('Unknown Asset')).toBeInTheDocument();
expect(screen.getByText('0.00')).toBeInTheDocument();
});

it('exposes accessible sort state, risk tooltips, and stale labels', async () => {
const user = userEvent.setup();
const fetchedAt = new Date(Date.now() - 20 * 60 * 1000).toISOString();

mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => [
{
protocol: 'Blend',
asset: 'USDC',
apy: 8.42,
tvl: 2450000,
risk: 'Low',
change24h: 0.32,
rewardTokens: ['BLND'],
category: 'Lending',
fetchedAt,
},
],
});

render(<ApyDashboard />);

expect(
await screen.findByRole('button', { name: /Blend USDC risk: Low/i }),
).toBeInTheDocument();
expect(screen.getByLabelText(/Stale data, \d+ minutes old/i)).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /Table/i }));

expect(screen.getByRole('columnheader', { name: /APY/i })).toHaveAttribute(
'aria-sort',
'descending',
);

const tvlSort = screen.getByRole('button', { name: /^Sort by TVL$/i });
expect(tvlSort).toHaveAttribute('aria-pressed', 'false');

await user.click(tvlSort);

expect(screen.getByRole('columnheader', { name: /TVL/i })).toHaveAttribute(
'aria-sort',
'descending',
);
expect(
screen.getByRole('button', { name: /Sort by TVL, currently descending/i }),
).toHaveAttribute('aria-pressed', 'true');
});
});