Skip to content
Merged
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
9 changes: 9 additions & 0 deletions src/__tests__/ClaimHistoryTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ describe('ClaimHistoryTable', () => {
expect(html).toContain('Processing');
});

it('links each claim row and mobile card to its policy details', () => {
const html = renderToStaticMarkup(
<ClaimHistoryTable claims={[makeClaim({ id: 'claim-1', policyId: 'policy-123' })]} />,
);

expect(html.match(/href="\/policies\/policy-123"/g)).toHaveLength(4);
expect(html).toContain('aria-label="View policy for claim claim-1"');
});

it('displays trigger status correctly', () => {
const claims = [
makeClaim({ id: 'claim-1', triggerMet: true }),
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/OracleDataWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { render } from '@testing-library/react';
import { OracleDataWidget } from '../components/OracleDataWidget';

vi.mock('@/hooks/useOracle', () => ({
Expand Down Expand Up @@ -46,6 +47,26 @@ describe('OracleDataWidget', () => {
vi.clearAllMocks();
});

it('does not rerender when its parent rerenders with unchanged props', () => {
mockUseOracleReading.mockReturnValue({
reading: null,
loading: false,
error: null,
refetch: vi.fn(),
});

const { rerender } = render(
<OracleDataWidget oracleKey="rainfall:1,1:2025-01" className="widget" />,
);
expect(mockUseOracleReading).toHaveBeenCalledTimes(1);

rerender(<OracleDataWidget oracleKey="rainfall:1,1:2025-01" className="widget" />);
expect(mockUseOracleReading).toHaveBeenCalledTimes(1);

rerender(<OracleDataWidget oracleKey="rainfall:2,2:2025-01" className="widget" />);
expect(mockUseOracleReading).toHaveBeenCalledTimes(2);
});

it('shows skeleton when loading', () => {
mockUseOracleReading.mockReturnValue({
reading: null,
Expand Down
30 changes: 29 additions & 1 deletion src/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@ const localStorageMock = (() => {

Object.defineProperty(window, 'localStorage', { value: localStorageMock });

const sessionStorageMock = {
getItem: vi.fn<(key: string) => string | null>(() => null),
setItem: vi.fn<(key: string, value: string) => void>(),
removeItem: vi.fn<(key: string) => void>(),
};

Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });

describe('storage', () => {
beforeEach(() => localStorageMock.clear());

it('sets and gets string values', () => {
storage.set('key', 'value');
expect(storage.set('key', 'value')).toBe(true);
expect(storage.get('key')).toBe('value');
});

Expand All @@ -40,4 +48,24 @@ describe('storage', () => {
localStorageMock.setItem('bad', 'not-valid-json{');
expect(storage.getJSON('bad')).toBeNull();
});

it('returns false instead of throwing when local storage quota is exceeded', () => {
const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
});

expect(storage.set('key', 'value')).toBe(false);
expect(() => storage.set('key', 'value')).not.toThrow();
setItem.mockRestore();
});

it('returns false instead of throwing when session storage quota is exceeded', () => {
sessionStorageMock.setItem.mockImplementation(() => {
throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
});

expect(storage.setSession('token', 'value')).toBe(false);
expect(() => storage.setSession('token', 'value')).not.toThrow();
sessionStorageMock.setItem.mockReset();
});
});
8 changes: 7 additions & 1 deletion src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,13 @@ export default function DashboardPage() {
className="border-b border-white/5 transition-colors hover:bg-white/[0.02]"
>
<td className="px-6 py-4 font-mono text-xs text-gray-400">
{claim.id.slice(0, 8)}…
<Link
href={`/policies/${claim.policyId}`}
aria-label={`View policy for claim ${claim.id}`}
className="rounded-sm text-teal-400 transition-colors hover:text-teal-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{claim.id.slice(0, 8)}…
</Link>
</td>
<td className="px-6 py-4 text-xs text-gray-400">
{formatDateTime(claim.submittedAt)}
Expand Down
28 changes: 19 additions & 9 deletions src/components/ClaimHistoryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,21 @@ function ClaimHistoryTableComponent({ claims, className }: ClaimHistoryTableProp
{sortedClaims.map((claim) => (
<tr
key={claim.id}
className="border-b border-white/5 transition-colors hover:bg-white/[0.02]"
className="relative border-b border-white/5 transition-colors hover:bg-white/[0.02]"
>
<td className="py-4 pr-4 font-mono text-xs text-gray-400">
{claim.id.slice(0, 8)}…
<Link
href={`/policies/${claim.policyId}`}
aria-label={`View policy for claim ${claim.id}`}
className="rounded-sm transition-colors before:absolute before:inset-0 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
>
{claim.id.slice(0, 8)}…
</Link>
</td>
<td className="py-4 pr-4 font-mono text-xs">
<Link
href={`/policies/${claim.policyId}`}
className="text-teal-400 hover:text-teal-300 transition-colors"
className="relative z-10 rounded-sm text-teal-400 transition-colors hover:text-teal-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{claim.policyId.slice(0, 8)}…
</Link>
Expand All @@ -189,7 +195,7 @@ function ClaimHistoryTableComponent({ claims, className }: ClaimHistoryTableProp
</td>
<td className="py-4 pr-4">
{claim.txHash ? (
<TransactionLink txHash={claim.txHash} />
<TransactionLink txHash={claim.txHash} className="relative z-10" />
) : (
<span className="text-xs text-gray-400">—</span>
)}
Expand All @@ -208,19 +214,23 @@ function ClaimHistoryTableComponent({ claims, className }: ClaimHistoryTableProp
{sortedClaims.map((claim) => (
<div
key={claim.id}
className="rounded-xl border border-white/10 bg-white/[0.02] p-4 space-y-3"
className="relative rounded-xl border border-white/10 bg-white/[0.02] p-4 space-y-3 transition-colors hover:border-white/20"
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-gray-400">
<Link
href={`/policies/${claim.policyId}`}
aria-label={`View policy for claim ${claim.id}`}
className="rounded-sm font-mono text-xs text-gray-400 before:absolute before:inset-0 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{claim.id.slice(0, 8)}…
</span>
</Link>
<Badge label={claim.status} />
</div>
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-xs">
<span className="text-gray-400">Policy</span>
<Link
href={`/policies/${claim.policyId}`}
className="font-mono text-teal-400 hover:text-teal-300 transition-colors truncate"
className="relative z-10 truncate rounded-sm font-mono text-teal-400 transition-colors hover:text-teal-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{claim.policyId.slice(0, 8)}…
</Link>
Expand All @@ -237,7 +247,7 @@ function ClaimHistoryTableComponent({ claims, className }: ClaimHistoryTableProp
<span className="text-gray-400">Tx</span>
<span>
{claim.txHash ? (
<TransactionLink txHash={claim.txHash} />
<TransactionLink txHash={claim.txHash} className="relative z-10" />
) : (
<span className="text-gray-400">—</span>
)}
Expand Down
11 changes: 9 additions & 2 deletions src/components/OracleDataWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useCallback } from 'react';
import { memo, useState, useCallback } from 'react';
import { useOracleReading } from '@/hooks/useOracle';
import { Skeleton } from './Skeleton';
import { formatOracleValue, formatDateTime, formatUtcDateTime } from '@/lib/format';
Expand All @@ -11,7 +11,7 @@ interface OracleDataWidgetProps {
className?: string;
}

export function OracleDataWidget({ oracleKey, className }: OracleDataWidgetProps) {
function OracleDataWidgetComponent({ oracleKey, className }: OracleDataWidgetProps) {
const { reading, loading, error, refetch } = useOracleReading(oracleKey);
const [refreshing, setRefreshing] = useState(false);

Expand Down Expand Up @@ -94,3 +94,10 @@ export function OracleDataWidget({ oracleKey, className }: OracleDataWidgetProps
</div>
);
}

export const OracleDataWidget = memo(
OracleDataWidgetComponent,
(previous, next) => previous.oracleKey === next.oracleKey && previous.className === next.className,
);

OracleDataWidget.displayName = 'OracleDataWidget';
41 changes: 30 additions & 11 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,24 @@ function get(key: string): string | null {
catch { return null; }
}

function set(key: string, value: string): void {
if (!isClient) return;
try { localStorage.setItem(key, value); }
catch { /* quota exceeded — ignore */ }
function isQuotaExceededError(error: unknown): boolean {
return error instanceof DOMException && (
error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
error.code === 22 ||
error.code === 1014
);
}

function set(key: string, value: string): boolean {
if (!isClient) return false;
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
if (isQuotaExceededError(error)) return false;
return false;
}
}

function remove(key: string): void {
Expand All @@ -25,9 +39,9 @@ function getJSON<T>(key: string): T | null {
catch { return null; }
}

function setJSON<T>(key: string, value: T): void {
try { set(key, JSON.stringify(value)); }
catch { /* ignore */ }
function setJSON<T>(key: string, value: T): boolean {
try { return set(key, JSON.stringify(value)); }
catch { return false; }
}

function getSession(key: string): string | null {
Expand All @@ -36,10 +50,15 @@ function getSession(key: string): string | null {
catch { return null; }
}

function setSession(key: string, value: string): void {
if (!isClient) return;
try { sessionStorage.setItem(key, value); }
catch { /* quota exceeded — ignore */ }
function setSession(key: string, value: string): boolean {
if (!isClient) return false;
try {
sessionStorage.setItem(key, value);
return true;
} catch (error) {
if (isQuotaExceededError(error)) return false;
return false;
}
}

function removeSession(key: string): void {
Expand Down