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: 32 additions & 18 deletions components/PredictionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { OutcomeIcon } from '@/components/icons/OutcomeIcons';
import type { OutcomeVariant } from '@/components/icons/OutcomeIcons';
import { getCategoryEmojiUrl } from '@/lib/categories/emojiMap';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip } from '@/app/components/Tooltip';

interface PredictionCardProps {
/** The prediction data to display. When omitted, a themed skeleton is rendered. */
Expand All @@ -22,6 +23,14 @@ const statusMap: Record<PredictionStatus, { icon: React.ElementType; label: stri
active: { icon: Activity, label: 'Active', className: 'text-blue-600 dark:text-blue-500 border-blue-600/20 bg-blue-50/50 dark:bg-blue-500/10' },
};

// Tooltip descriptions for each status
const statusTooltip: Record<PredictionStatus, string> = {
won: 'This prediction was correct',
lost: 'This prediction was incorrect',
pending: 'This prediction is awaiting resolution',
active: 'This prediction is currently active',
};

/**
* Maps a PredictionStatus to a shape-based OutcomeVariant so that status
* differentiation does not rely on color alone (WCAG 2.1 AA 1.4.1).
Expand Down Expand Up @@ -129,16 +138,16 @@ const PredictionCard: React.FC<PredictionCardProps> = ({ prediction }) => {
)}
</div>
<Badge variant="outline" className={`gap-1.5 shrink-0 ${className}`} aria-label={`Status: ${label}`}>
{/*
* Shape icon (color-blind safe) — rendered BEFORE the status icon.
* aria-hidden because the Badge's aria-label already names the status.
* Distinguishable by shape under Deuteranopia & Tritanopia simulations.
*/}
<OutcomeIcon
variant={statusOutcomeVariant[status]}
aria-hidden
/>
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
{/* Shape icon (color-blind safe) — rendered BEFORE the status icon. */}
<Tooltip content={`Status shape indicator: ${statusOutcomeVariant[status]} outcome`} placement="top">
<OutcomeIcon
variant={statusOutcomeVariant[status]}
aria-hidden
/>
</Tooltip>
<Tooltip content={statusTooltip[status]} placement="top">
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
</Tooltip>
{label}
</Badge>
</div>
Expand All @@ -156,14 +165,19 @@ const PredictionCard: React.FC<PredictionCardProps> = ({ prediction }) => {
<Collapsible open={isOddsExpanded} onOpenChange={setIsOddsExpanded}>
<CollapsibleTrigger asChild>
{/* touch-target: guarantees ≥44px tap area on the Odds trigger (WCAG 2.5.5). */}
<button
className="touch-target touch-ripple flex w-full items-center justify-between px-2 rounded hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={isOddsExpanded}
aria-controls="odds-breakdown"
<Tooltip
content={isOddsExpanded ? 'Click to collapse odds details' : 'Click to expand odds details'}
placement="top"
>
<p className="text-muted-foreground">Odds</p>
<p className="text-card-foreground font-medium tabular-nums">{odds.toFixed(1)}x</p>
</button>
<button
className="touch-target touch-ripple flex w-full items-center justify-between px-2 rounded hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={isOddsExpanded}
aria-controls="odds-breakdown"
>
<p className="text-muted-foreground">Odds</p>
<p className="text-card-foreground font-medium tabular-nums">{odds.toFixed(1)}x</p>
</button>
</Tooltip>
</CollapsibleTrigger>
<CollapsibleContent id="odds-breakdown" className="mt-2 text-sm text-muted-foreground">
<p className="tabular-nums">Implied probability: {(1 / odds * 100).toFixed(1)}%</p>
Expand Down Expand Up @@ -196,4 +210,4 @@ const PredictionCard: React.FC<PredictionCardProps> = ({ prediction }) => {
);
};

export default PredictionCard;
export default PredictionCard;
78 changes: 77 additions & 1 deletion components/__tests__/PredictionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,82 @@ describe('PredictionsList loading state', () => {
});
});

// --- Tooltip Tests ---

describe('PredictionCard tooltips', () => {
it('renders tooltip content on the status icon when hovered', async () => {
const user = userEvent.setup();
render(<PredictionCard prediction={mockPrediction} />);

// The Tooltip component wraps the icon. On hover, the tooltip content should appear.
// Instead of targeting the icon directly (which is aria-hidden), hover the status badge area.
const statusBadge = screen.getByLabelText('Status: Active');
await user.hover(statusBadge);

// The tooltip for 'active' status should appear
expect(await screen.findByText('This prediction is currently active')).toBeInTheDocument();
});

it('renders correct tooltip content for each status variant', async () => {
const user = userEvent.setup();
const statuses: Array<{ status: Prediction['status']; tooltip: string }> = [
{ status: 'won', tooltip: 'This prediction was correct' },
{ status: 'lost', tooltip: 'This prediction was incorrect' },
{ status: 'pending', tooltip: 'This prediction is awaiting resolution' },
];

for (const { status, tooltip } of statuses) {
const prediction: Prediction = {
...mockPrediction,
id: `${status}-test`,
status,
...(status === 'won' || status === 'lost' ? { resolvedDate: '01/06/2023' } : {}),
};
const { unmount } = render(<PredictionCard prediction={prediction} />);

const badge = screen.getByLabelText(`Status: ${status.charAt(0).toUpperCase() + status.slice(1)}`);
await user.hover(badge);
expect(await screen.findByText(tooltip)).toBeInTheDocument();

unmount();
}
});

it('shows odds expand/collapse tooltip on the odds trigger', async () => {
const user = userEvent.setup();
render(<PredictionCard prediction={mockPrediction} />);

// Hover over the odds trigger button
const oddsTrigger = document.querySelector('[aria-controls="odds-breakdown"]') as HTMLElement;
await user.hover(oddsTrigger);

// Should show the expand tooltip initially
expect(await screen.findByText('Click to expand odds details')).toBeInTheDocument();
});

it('updates odds tooltip after expanding', async () => {
const user = userEvent.setup();
render(<PredictionCard prediction={mockPrediction} />);

const oddsTrigger = document.querySelector('[aria-controls="odds-breakdown"]') as HTMLElement;

// Click to expand
await user.click(oddsTrigger);
expect(oddsTrigger).toHaveAttribute('aria-expanded', 'true');

// Now hover again - should show "collapse" tooltip
await user.hover(oddsTrigger);
expect(await screen.findByText('Click to collapse odds details')).toBeInTheDocument();
});

it('does not show tooltip content by default (no hover)', () => {
render(<PredictionCard prediction={mockPrediction} />);

expect(screen.queryByText('This prediction is currently active')).toBeNull();
expect(screen.queryByText('Click to expand odds details')).toBeNull();
});
});

// --- Touch Target Tests (WCAG 2.5.5 / Apple HIG ≥44px) ---

describe('PredictionCard touch targets', () => {
Expand Down Expand Up @@ -194,4 +270,4 @@ describe('PredictionCard touch targets', () => {
await user.click(oddsTrigger);
expect(oddsTrigger).toHaveAttribute('aria-expanded', 'true');
});
});
});