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
70 changes: 70 additions & 0 deletions frontend/src/__tests__/utils/dateHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { formatDate, getRemainingDays } from '../../utils/dateHelpers';

describe('formatDate', () => {
it('returns N/A for empty string', () => {
expect(formatDate('')).toBe('N/A');
});

it('formats a date-only string (YYYY-MM-DD) correctly', () => {
const result = formatDate('2024-01-15');
expect(result).toBe('Jan 15, 2024');
});

it('formats an ISO datetime string correctly', () => {
const result = formatDate('2024-06-01T10:00:00Z');
expect(result).toContain('2024');
expect(result).toContain('Jun');
});

it('returns the original string for an invalid date', () => {
expect(formatDate('not-a-date')).toBe('not-a-date');
});

it('handles single-digit month and day', () => {
const result = formatDate('2024-03-05');
expect(result).toBe('Mar 5, 2024');
});

it('handles Dec 31 edge case', () => {
const result = formatDate('2024-12-31');
expect(result).toBe('Dec 31, 2024');
});
});

describe('getRemainingDays', () => {
it('returns 0 for an invalid date string', () => {
expect(getRemainingDays('garbage')).toBe(0);
});

it('returns 0 for today', () => {
const today = new Date();
expect(getRemainingDays(today)).toBe(0);
});

it('returns a positive number for a future date', () => {
const future = new Date();
future.setDate(future.getDate() + 10);
expect(getRemainingDays(future)).toBe(10);
});

it('returns a negative number for a past date', () => {
const past = new Date();
past.setDate(past.getDate() - 5);
expect(getRemainingDays(past)).toBe(-5);
});

it('accepts a date string', () => {
const future = new Date();
future.setDate(future.getDate() + 3);
const iso = future.toISOString();
expect(getRemainingDays(iso)).toBe(3);
});

it('uses day-level precision, not time-level', () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
expect(getRemainingDays(tomorrow)).toBe(1);
});
});
3 changes: 1 addition & 2 deletions frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { Breadcrumb } from './Breadcrumb';
import { NetworkSwitcher } from './NetworkSwitcher';
import { useNetworkStore } from '../stores/networkStore';

const APP_VERSION =
(import.meta.env.PUBLIC_APP_VERSION as string | undefined)?.trim() ?? '0.0.1';
const APP_VERSION = (import.meta.env.PUBLIC_APP_VERSION as string | undefined)?.trim() ?? '0.0.1';
const APP_ENV = import.meta.env.MODE;

// ── Page Wrapper ───────────────────────
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const Avatar: React.FC<AvatarProps> = ({
<img
src={avatarUrl}
alt={name}
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
setHasImageError(true);
Expand Down
13 changes: 3 additions & 10 deletions frontend/src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ export function buildCrumbs(pathname: string): Crumb[] {
let accumulated = '';
for (const segment of segments) {
accumulated += `/${segment}`;
const label =
ROUTE_LABELS[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1);
const label = ROUTE_LABELS[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1);
crumbs.push({ label, href: accumulated });
}

Expand All @@ -61,15 +60,9 @@ export const Breadcrumb: React.FC = () => {
const isLast = i === crumbs.length - 1;
return (
<React.Fragment key={crumb.href}>
{i > 0 && (
<ChevronRight className="h-3 w-3 shrink-0 opacity-50" aria-hidden />
)}
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0 opacity-50" aria-hidden />}
{isLast ? (
<span
className="font-medium"
style={{ color: 'var(--text)' }}
aria-current="page"
>
<span className="font-medium" style={{ color: 'var(--text)' }} aria-current="page">
{crumb.label}
</span>
) : (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ContractErrorPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
backdrop-filter: blur(10px);
}

Expand Down
9 changes: 2 additions & 7 deletions frontend/src/components/EmployeeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,7 @@ export const EmployeeList: React.FC<EmployeeListProps> = ({
</thead>
<tbody className="divide-y divide-gray-200">
{isLoading ? (
Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
<EmployeeSkeletonRow key={i} />
))
Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => <EmployeeSkeletonRow key={i} />)
) : sortedEmployees.length === 0 ? (
<tr>
<td colSpan={6} className="p-6 text-center text-gray-500">
Expand All @@ -266,10 +264,7 @@ export const EmployeeList: React.FC<EmployeeListProps> = ({
</tr>
) : (
sortedEmployees.map((employee) => (
<tr
key={employee.id}
className="cursor-pointer transition-colors hover:bg-white/5"
>
<tr key={employee.id} className="cursor-pointer transition-colors hover:bg-white/5">
<td className="p-6">
<div className="flex items-center gap-3">
<Avatar
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/FeeEstimationPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease;
}

.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-md);
}

.cardTitle {
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/__tests__/AppNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ vi.mock('../../hooks/useWallet', () => ({
}));

// Dynamic import so mocks are registered first
const importNav = () =>
import('../AppNav').then((m) => m.default);
const importNav = () => import('../AppNav').then((m) => m.default);

describe('AppNav — mobile drawer', () => {
test('hamburger button is present and drawer is hidden initially', async () => {
Expand Down
5 changes: 1 addition & 4 deletions frontend/src/components/__tests__/Breadcrumb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,7 @@ describe('Breadcrumb component', () => {
</MemoryRouter>
);
expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: /employer/i })).toHaveAttribute(
'href',
'/employer'
);
expect(screen.getByRole('link', { name: /employer/i })).toHaveAttribute('href', '/employer');
expect(screen.getByText('Payroll')).toHaveAttribute('aria-current', 'page');
});
});
8 changes: 2 additions & 6 deletions frontend/src/components/__tests__/EmployeeListHover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ const employee = {

describe('EmployeeList row hover effects', () => {
test('data rows include hover background class', () => {
const { container } = render(
<EmployeeList employees={[employee]} onAddEmployee={vi.fn()} />
);
const { container } = render(<EmployeeList employees={[employee]} onAddEmployee={vi.fn()} />);

const rows = container.querySelectorAll('tbody tr');
expect(rows.length).toBeGreaterThan(0);
Expand All @@ -36,9 +34,7 @@ describe('EmployeeList row hover effects', () => {
});

test('data rows include transition class for smooth hover animation', () => {
const { container } = render(
<EmployeeList employees={[employee]} onAddEmployee={vi.fn()} />
);
const { container } = render(<EmployeeList employees={[employee]} onAddEmployee={vi.fn()} />);

const rows = container.querySelectorAll('tbody tr');
rows.forEach((row) => {
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/components/__tests__/NetworkSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ describe('NetworkSwitcher', () => {

test('renders a select element with an accessible label', () => {
render(<NetworkSwitcher />);
expect(
screen.getByRole('combobox', { name: /select stellar network/i })
).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /select stellar network/i })).toBeInTheDocument();
});

test('shows MAINNET as the default selected option', () => {
Expand Down Expand Up @@ -66,8 +64,6 @@ describe('NetworkSwitcher', () => {

test('wraps select in a group with an accessible label', () => {
render(<NetworkSwitcher />);
expect(
screen.getByRole('group', { name: /stellar network selector/i })
).toBeInTheDocument();
expect(screen.getByRole('group', { name: /stellar network selector/i })).toBeInTheDocument();
});
});
15 changes: 14 additions & 1 deletion frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
--font-head: 'Syne', sans-serif;
--font-body: 'Inter', sans-serif;
--font-mono: 'DM Mono', monospace;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.16);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.24);
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.18);
--shadow-card-hover: 0 6px 20px rgba(0, 0, 0, 0.28);
color-scheme: dark;
}

Expand All @@ -44,6 +49,11 @@
--font-head: 'Syne', sans-serif;
--font-body: 'Inter', sans-serif;
--font-mono: 'DM Mono', monospace;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 6px 20px rgba(0, 0, 0, 0.1);
color-scheme: light;
}

Expand Down Expand Up @@ -121,13 +131,16 @@ h6 {
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
box-shadow: var(--shadow-card);
transition:
transform 0.2s ease,
border-color 0.2s ease;
border-color 0.2s ease,
box-shadow 0.2s ease;
}

.card:hover {
border-color: var(--border-hi);
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}

Expand Down
21 changes: 1 addition & 20 deletions frontend/src/pages/PayrollScheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ContractErrorPanel } from '../components/ContractErrorPanel';
import { IssuerMultisigBanner } from '../components/IssuerMultisigBanner';
import { HelpLink } from '../components/HelpLink';
import { parseContractError, type ContractErrorDetail } from '../utils/contractErrorParser';
import { formatDate } from '../utils/dateHelpers';

interface PayrollFormState {
employeeName: string;
Expand Down Expand Up @@ -120,26 +121,6 @@ function computeNextRunDate(config: SchedulingConfig, from: Date = new Date()):
return first;
}

const formatDate = (dateString: string) => {
if (!dateString) return 'N/A';

const dateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateString);
const date = dateOnlyMatch
? new Date(
Number.parseInt(dateOnlyMatch[1], 10),
Number.parseInt(dateOnlyMatch[2], 10) - 1,
Number.parseInt(dateOnlyMatch[3], 10)
)
: new Date(dateString);

if (isNaN(date.getTime())) return dateString;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};

interface PendingClaim {
id: string;
employeeName: string;
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/providers/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,7 @@ export const WalletProvider: React.FC<{
const message =
error instanceof Error ? error.message : 'Unable to connect to the selected wallet.';
setConnectionError(message);
notifyWalletEvent(
'connection_failed',
message
);
notifyWalletEvent('connection_failed', message);
return null;
} finally {
setIsConnecting(false);
Expand Down Expand Up @@ -255,7 +252,12 @@ export const WalletProvider: React.FC<{
>
<div className="flex items-center gap-3">
{wallet.icon ? (
<img src={wallet.icon} alt={wallet.name} className="h-6 w-6 rounded" />
<img
src={wallet.icon}
alt={wallet.name}
loading="lazy"
className="h-6 w-6 rounded"
/>
) : (
<div className="h-6 w-6 rounded bg-white/10" />
)}
Expand Down
5 changes: 1 addition & 4 deletions frontend/src/providers/__tests__/WalletProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ describe('WalletProvider', () => {
});

expect(screen.queryByRole('dialog', { name: /connect to payd/i })).not.toBeInTheDocument();
expect(mockNotifyWalletEvent).toHaveBeenCalledWith(
'connected',
'GABCD1...LLET via freighter'
);
expect(mockNotifyWalletEvent).toHaveBeenCalledWith('connected', 'GABCD1...LLET via freighter');
});

it('finishes initialization when silent reconnect hangs', async () => {
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/stores/networkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { persist } from 'zustand/middleware';
export type StellarNetwork = 'TESTNET' | 'MAINNET';

function getDefaultNetwork(): StellarNetwork {
const env = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)
?.toUpperCase()
?.trim();
const env = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase()?.trim();
return env === 'TESTNET' ? 'TESTNET' : 'MAINNET';
}

Expand Down
31 changes: 31 additions & 0 deletions frontend/src/utils/dateHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function formatDate(dateString: string): string {
if (!dateString) return 'N/A';

const dateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateString);
const date = dateOnlyMatch
? new Date(
Number.parseInt(dateOnlyMatch[1], 10),
Number.parseInt(dateOnlyMatch[2], 10) - 1,
Number.parseInt(dateOnlyMatch[3], 10)
)
: new Date(dateString);

if (isNaN(date.getTime())) return dateString;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

export function getRemainingDays(targetDate: string | Date): number {
const target = typeof targetDate === 'string' ? new Date(targetDate) : targetDate;
if (isNaN(target.getTime())) return 0;

const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfTarget = new Date(target.getFullYear(), target.getMonth(), target.getDate());

const diffMs = startOfTarget.getTime() - startOfToday.getTime();
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
}
Loading