diff --git a/frontend/src/__tests__/utils/dateHelpers.test.ts b/frontend/src/__tests__/utils/dateHelpers.test.ts new file mode 100644 index 00000000..b2cd2b92 --- /dev/null +++ b/frontend/src/__tests__/utils/dateHelpers.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index bd2cb62e..192bce22 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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 ─────────────────────── diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 92a8913e..b279bb05 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -49,6 +49,7 @@ export const Avatar: React.FC = ({ {name} { setHasImageError(true); diff --git a/frontend/src/components/Breadcrumb.tsx b/frontend/src/components/Breadcrumb.tsx index 3472fa25..2d9aac5d 100644 --- a/frontend/src/components/Breadcrumb.tsx +++ b/frontend/src/components/Breadcrumb.tsx @@ -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 }); } @@ -61,15 +60,9 @@ export const Breadcrumb: React.FC = () => { const isLast = i === crumbs.length - 1; return ( - {i > 0 && ( - - )} + {i > 0 && } {isLast ? ( - + {crumb.label} ) : ( diff --git a/frontend/src/components/ContractErrorPanel.module.css b/frontend/src/components/ContractErrorPanel.module.css index 3d57da19..700ba081 100644 --- a/frontend/src/components/ContractErrorPanel.module.css +++ b/frontend/src/components/ContractErrorPanel.module.css @@ -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); } diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index c0d98a0e..75645bd1 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -255,9 +255,7 @@ export const EmployeeList: React.FC = ({ {isLoading ? ( - Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( - - )) + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ) ) : sortedEmployees.length === 0 ? ( @@ -266,10 +264,7 @@ export const EmployeeList: React.FC = ({ ) : ( sortedEmployees.map((employee) => ( - +
({ })); // 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 () => { diff --git a/frontend/src/components/__tests__/Breadcrumb.test.tsx b/frontend/src/components/__tests__/Breadcrumb.test.tsx index a4736209..35393297 100644 --- a/frontend/src/components/__tests__/Breadcrumb.test.tsx +++ b/frontend/src/components/__tests__/Breadcrumb.test.tsx @@ -97,10 +97,7 @@ describe('Breadcrumb component', () => { ); 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'); }); }); diff --git a/frontend/src/components/__tests__/EmployeeListHover.test.tsx b/frontend/src/components/__tests__/EmployeeListHover.test.tsx index c2b0bb09..78fbee3c 100644 --- a/frontend/src/components/__tests__/EmployeeListHover.test.tsx +++ b/frontend/src/components/__tests__/EmployeeListHover.test.tsx @@ -23,9 +23,7 @@ const employee = { describe('EmployeeList row hover effects', () => { test('data rows include hover background class', () => { - const { container } = render( - - ); + const { container } = render(); const rows = container.querySelectorAll('tbody tr'); expect(rows.length).toBeGreaterThan(0); @@ -36,9 +34,7 @@ describe('EmployeeList row hover effects', () => { }); test('data rows include transition class for smooth hover animation', () => { - const { container } = render( - - ); + const { container } = render(); const rows = container.querySelectorAll('tbody tr'); rows.forEach((row) => { diff --git a/frontend/src/components/__tests__/NetworkSwitcher.test.tsx b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx index e68dc461..33d7b5a0 100644 --- a/frontend/src/components/__tests__/NetworkSwitcher.test.tsx +++ b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx @@ -25,9 +25,7 @@ describe('NetworkSwitcher', () => { test('renders a select element with an accessible label', () => { render(); - 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', () => { @@ -66,8 +64,6 @@ describe('NetworkSwitcher', () => { test('wraps select in a group with an accessible label', () => { render(); - expect( - screen.getByRole('group', { name: /stellar network selector/i }) - ).toBeInTheDocument(); + expect(screen.getByRole('group', { name: /stellar network selector/i })).toBeInTheDocument(); }); }); diff --git a/frontend/src/index.css b/frontend/src/index.css index de44f606..8911d922 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } @@ -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; } @@ -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); } diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 2f17eb90..f3cc8899 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -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; @@ -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; diff --git a/frontend/src/providers/WalletProvider.tsx b/frontend/src/providers/WalletProvider.tsx index a93fb8b7..09d2cdca 100644 --- a/frontend/src/providers/WalletProvider.tsx +++ b/frontend/src/providers/WalletProvider.tsx @@ -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); @@ -255,7 +252,12 @@ export const WalletProvider: React.FC<{ >
{wallet.icon ? ( - {wallet.name} + {wallet.name} ) : (
)} diff --git a/frontend/src/providers/__tests__/WalletProvider.test.tsx b/frontend/src/providers/__tests__/WalletProvider.test.tsx index 4e6e9ac5..6b6837c7 100644 --- a/frontend/src/providers/__tests__/WalletProvider.test.tsx +++ b/frontend/src/providers/__tests__/WalletProvider.test.tsx @@ -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 () => { diff --git a/frontend/src/stores/networkStore.ts b/frontend/src/stores/networkStore.ts index 875295c4..764749ed 100644 --- a/frontend/src/stores/networkStore.ts +++ b/frontend/src/stores/networkStore.ts @@ -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'; } diff --git a/frontend/src/utils/dateHelpers.ts b/frontend/src/utils/dateHelpers.ts new file mode 100644 index 00000000..daf44009 --- /dev/null +++ b/frontend/src/utils/dateHelpers.ts @@ -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)); +} diff --git a/src/pages/PayrollScheduler.tsx b/src/pages/PayrollScheduler.tsx index 23e25bac..46384b4e 100644 --- a/src/pages/PayrollScheduler.tsx +++ b/src/pages/PayrollScheduler.tsx @@ -5,8 +5,12 @@ export default function PayrollScheduler() { return (
-

Payroll Scheduler

-

Automate recurring payments to your workforce.

+

+ Payroll Scheduler +

+

+ Automate recurring payments to your workforce. +

@@ -16,7 +20,8 @@ export default function PayrollScheduler() {

No Active Schedules

- You haven't set up any automated payroll schedules yet. Connect your wallet to get started. + You haven't set up any automated payroll schedules yet. Connect your + wallet to get started.