diff --git a/src/components/ActivityDateGroup.test.tsx b/src/components/ActivityDateGroup.test.tsx new file mode 100644 index 0000000..206daed --- /dev/null +++ b/src/components/ActivityDateGroup.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ActivityDateGroup, { formatDateLabel } from './ActivityDateGroup'; + +describe('ActivityDateGroup', () => { + it('renders "Today" for current date', () => { + const today = new Date().toISOString().split('T')[0]; + render(); + expect(screen.getByText('Today')).toBeTruthy(); + }); + + it('renders "Yesterday" for yesterday', () => { + const yesterday = new Date(Date.now() - 86_400_000).toISOString().split('T')[0]; + render(); + expect(screen.getByText('Yesterday')).toBeTruthy(); + }); + + it('renders weekday name for dates within 7 days', () => { + const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000).toISOString().split('T')[0]; + render(); + const element = screen.getByRole('separator'); + const label = element.textContent || ''; + // Should be a weekday name, not a numeric date + const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + expect(weekdays.some(d => label.includes(d))).toBe(true); + }); + + it('renders "Last Week" for dates 7-13 days ago', () => { + const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString().split('T')[0]; + render(); + expect(screen.getByText('Last Week')).toBeTruthy(); + }); + + it('renders "This Month" for dates 14-29 days ago', () => { + const twentyDaysAgo = new Date(Date.now() - 20 * 86_400_000).toISOString().split('T')[0]; + render(); + expect(screen.getByText('This Month')).toBeTruthy(); + }); + + it('renders full date for older dates', () => { + render(); + const element = screen.getByRole('separator'); + expect(element.getAttribute('aria-label')).toContain('2025'); + }); + + it('has sticky positioning', () => { + render(); + const element = document.querySelector('.activity-date-group'); + expect(element).toBeTruthy(); + const styles = getComputedStyle(element!); + expect(styles.position).toBe('sticky'); + }); + + it('has data-date attribute for CSS targeting', () => { + render(); + const element = document.querySelector('[data-date="2026-08-01"]'); + expect(element).toBeTruthy(); + }); +}); + +describe('formatDateLabel', () => { + it('returns "Today" for current date', () => { + const today = new Date().toISOString().split('T')[0]; + expect(formatDateLabel(today)).toBe('Today'); + }); + + it('returns "Yesterday" for yesterday', () => { + const yesterday = new Date(Date.now() - 86_400_000).toISOString().split('T')[0]; + expect(formatDateLabel(yesterday)).toBe('Yesterday'); + }); +}); diff --git a/src/components/ActivityDateGroup.tsx b/src/components/ActivityDateGroup.tsx index f41b2d9..bb61702 100644 --- a/src/components/ActivityDateGroup.tsx +++ b/src/components/ActivityDateGroup.tsx @@ -2,13 +2,53 @@ import React from 'react'; import './ActivityDateGroup.css'; interface Props { - date: string; + date: string; // ISO date string } -const ActivityDateGroup: React.FC = ({ date }) => ( -
  • - {date} -
  • -); +/** + * Formats a date into a human-friendly label. + * - Today / Yesterday for recent dates + * - Day name for dates within this week + * - Full date for older dates + */ +function formatDateLabel(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + + // Reset time parts for date comparison + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.floor((today.getTime() - target.getTime()) / 86_400_000); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) { + return date.toLocaleDateString('en-US', { weekday: 'long' }); + } + if (diffDays < 14) return 'Last Week'; + if (diffDays < 30) return 'This Month'; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +} + +const ActivityDateGroup: React.FC = ({ date }) => { + const formattedDate = formatDateLabel(date); + + return ( +
  • + {formattedDate} +
  • + ); +}; + +export { formatDateLabel }; export default ActivityDateGroup; diff --git a/src/components/LedgerTable/LedgerTable.css b/src/components/LedgerTable/LedgerTable.css index 2ed530f..1572691 100644 --- a/src/components/LedgerTable/LedgerTable.css +++ b/src/components/LedgerTable/LedgerTable.css @@ -527,3 +527,44 @@ } + +/* ─── Column Resize Handle ─────────────────────────────────────── */ +.lt-cell { + position: relative; +} + +.lt-resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + background: transparent; + transition: background 0.15s ease; + z-index: 5; + user-select: none; +} + +.lt-resize-handle:hover, +.lt-resize-handle:active, +.lt-resize-handle--dragging { + background: var(--accent-color, #3b82f6); + opacity: 0.4; +} + +.lt-resize-handle--dragging { + opacity: 0.7; + width: 4px; +} + +/* Keyboard resize indicator */ +.lt-cell--resizing { + outline: 2px dashed var(--accent-color, #3b82f6); + outline-offset: -2px; +} + +/* Prevent text selection during resize */ +.lt-table-wrap--resizing { + user-select: none; +} diff --git a/src/components/LedgerTable/LedgerTable.resize.test.tsx b/src/components/LedgerTable/LedgerTable.resize.test.tsx new file mode 100644 index 0000000..a423deb --- /dev/null +++ b/src/components/LedgerTable/LedgerTable.resize.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import LedgerTable, { Column } from './LedgerTable'; + +interface TestRow { + id: string; + name: string; + amount: number; +} + +const columns: Column[] = [ + { key: 'name', label: 'Name', width: '200px', render: (r) => r.name }, + { key: 'amount', label: 'Amount', width: '150px', render: (r) => `$${r.amount}` }, +]; + +const data: TestRow[] = [ + { id: '1', name: 'Alice', amount: 100 }, + { id: '2', name: 'Bob', amount: 200 }, +]; + +describe('LedgerTable column resize', () => { + it('renders resize handles on header cells', () => { + render( + r.id} + stickyHeader={true} + /> + ); + const handles = document.querySelectorAll('.lt-resize-handle'); + expect(handles.length).toBe(2); + }); + + it('resize handle has correct ARIA role', () => { + render( + r.id} + /> + ); + const handles = document.querySelectorAll('[role="separator"]'); + expect(handles.length).toBeGreaterThanOrEqual(2); + }); + + it('supports keyboard resize via Ctrl+ArrowLeft/Right', () => { + render( + r.id} + /> + ); + const headerCells = document.querySelectorAll('.lt-cell--header'); + expect(headerCells.length).toBeGreaterThanOrEqual(2); + // Verify headers are present and resize handles exist + headerCells.forEach(cell => { + const handle = cell.querySelector('.lt-resize-handle'); + expect(handle).toBeTruthy(); + }); + }); +}); diff --git a/src/components/LedgerTable/LedgerTable.tsx b/src/components/LedgerTable/LedgerTable.tsx index fd3823e..ef3b2cc 100644 --- a/src/components/LedgerTable/LedgerTable.tsx +++ b/src/components/LedgerTable/LedgerTable.tsx @@ -49,6 +49,51 @@ const ROW_HEIGHTS: Record = { const OVERSCAN = 5; + +const ResizeHandle: React.FC<{ columnKey: string; onResize: (key: string, delta: number) => void }> = ({ + columnKey, + onResize, +}) => { + const [dragging, setDragging] = useState(false); + const startXRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(true); + startXRef.current = e.clientX; + }, []); + + useEffect(() => { + if (!dragging) return; + const handleMouseMove = (e: MouseEvent) => { + const delta = e.clientX - startXRef.current; + startXRef.current = e.clientX; + onResize(columnKey, delta); + }; + const handleMouseUp = () => setDragging(false); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging, columnKey, onResize]); + + return ( +
    + ); +}; + + type FlattenedRow = | { isGroup: true; key: string; value: any; items: T[] } | { isGroup: false; row: T }; @@ -76,6 +121,7 @@ function LedgerTable({ const [detailRow, setDetailRow] = useState(null); const [groupBy, setGroupBy] = useState(null); + const [columnWidths, setColumnWidths] = useState>({}); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const columnMenuRef = useRef(null); @@ -234,6 +280,13 @@ function LedgerTable({ [handleRowClick], ); + const handleResize = useCallback((key: string, delta: number) => { + setColumnWidths((prev) => ({ + ...prev, + [key]: Math.max(80, (prev[key] || 120) + delta), + })); + }, []); + const toggleGroup = useCallback((key: string) => { setCollapsedGroups(prev => { const next = new Set(prev); @@ -470,12 +523,16 @@ function LedgerTable({ {filteredColumns.map((col) => (
    {col.label} +
    ))}
    diff --git a/src/components/PayoutTimeline/conflictAnalysis.test.ts b/src/components/PayoutTimeline/conflictAnalysis.test.ts index c5e5092..049b509 100644 --- a/src/components/PayoutTimeline/conflictAnalysis.test.ts +++ b/src/components/PayoutTimeline/conflictAnalysis.test.ts @@ -1,25 +1,91 @@ -import { describe, it, expect } from 'vitest'; -import { analyzeConflicts } from './conflictAnalysis'; +import { analyzeConflicts, LockupPeriod, RedemptionWindow } from './conflictAnalysis'; import { PayoutEvent } from './PayoutTimeline'; -describe('analyzeConflicts', () => { - const mockPayouts: PayoutEvent[] = [ - { id: 'p1', date: '2026-07-01', label: 'Payout 1', status: 'scheduled' }, - { id: 'p2', date: '2026-07-10', label: 'Processing Payout', status: 'processing' }, +const makePayout = (id: string, date: string, status: PayoutEvent['status'] = 'upcoming'): PayoutEvent => ({ + id, + date, + label: `Payout ${id}`, + status, + amount: '1000', + token: 'USDC', +}); + +describe('analyzeConflicts - enhanced', () => { + const lockups: LockupPeriod[] = [ + { start: '2026-08-01', end: '2026-08-15', label: 'Q3 Lockup' }, + { start: '2026-09-01', end: '2026-09-30', label: 'Vesting Cliff' }, ]; - it('returns no conflicts for valid date', () => { - const conflicts = analyzeConflicts('p1', '2026-07-20', mockPayouts); - expect(conflicts).toHaveLength(0); + const redemptions: RedemptionWindow[] = [ + { date: '2026-08-20', label: 'Series A Redemption' }, + ]; + + it('detects exact date collision (hard)', () => { + const payouts = [makePayout('1', '2026-08-01'), makePayout('2', '2026-08-01')]; + const conflicts = analyzeConflicts('1', '2026-08-01', payouts); + const hard = conflicts.filter(c => c.severity === 'hard'); + expect(hard.length).toBeGreaterThanOrEqual(1); + expect(hard[0].message).toContain('already scheduled'); + }); + + it('detects lockup period overlap (hard)', () => { + const payouts = [makePayout('1', '2026-08-10')]; + const conflicts = analyzeConflicts('1', '2026-08-10', payouts, lockups); + const lockupConflict = conflicts.find(c => c.id.includes('lockup')); + expect(lockupConflict).toBeTruthy(); + expect(lockupConflict!.severity).toBe('hard'); + expect(lockupConflict!.message).toContain('Q3 Lockup'); + expect(lockupConflict!.suggestion).toBeTruthy(); + }); + + it('detects redemption window conflict (hard)', () => { + const payouts = [makePayout('1', '2026-08-20')]; + const conflicts = analyzeConflicts('1', '2026-08-20', payouts, [], redemptions); + const rwConflict = conflicts.find(c => c.id.includes('redemption')); + expect(rwConflict).toBeTruthy(); + expect(rwConflict!.severity).toBe('hard'); + }); + + it('detects soft proximity warning', () => { + const payouts = [makePayout('1', '2026-08-01'), makePayout('2', '2026-08-03', 'processing')]; + const conflicts = analyzeConflicts('1', '2026-08-01', payouts); + const soft = conflicts.find(c => c.severity === 'soft'); + expect(soft).toBeTruthy(); + }); + + it('detects near lockup boundary (soft)', () => { + const payouts = [makePayout('1', '2026-08-17')]; // 2 days after lockup ends + const conflicts = analyzeConflicts('1', '2026-08-17', payouts, lockups); + const boundary = conflicts.find(c => c.id.includes('lockup-boundary')); + expect(boundary).toBeTruthy(); + expect(boundary!.severity).toBe('soft'); + }); + + it('detects payout clustering (info)', () => { + const payouts = [ + makePayout('1', '2026-08-01'), + makePayout('2', '2026-08-01'), + makePayout('3', '2026-08-01'), + ]; + const conflicts = analyzeConflicts('1', '2026-08-01', payouts); + const cluster = conflicts.find(c => c.severity === 'info'); + expect(cluster).toBeTruthy(); + expect(cluster!.message).toContain('3 payouts'); }); - it('returns hard conflict for same date', () => { - const conflicts = analyzeConflicts('p1', '2026-07-10', mockPayouts); - expect(conflicts.some(c => c.severity === 'hard')).toBe(true); + it('returns suggestions with every conflict', () => { + const payouts = [makePayout('1', '2026-08-01'), makePayout('2', '2026-08-01')]; + const conflicts = analyzeConflicts('1', '2026-08-01', payouts, lockups, redemptions); + conflicts.forEach(c => { + expect(c.suggestion).toBeTruthy(); + expect(typeof c.suggestion).toBe('string'); + expect(c.suggestion!.length).toBeGreaterThan(10); + }); }); - it('returns soft conflict for within 7 days of processing payout', () => { - const conflicts = analyzeConflicts('p1', '2026-07-15', mockPayouts); - expect(conflicts.some(c => c.severity === 'soft')).toBe(true); + it('no conflicts when all clear', () => { + const payouts = [makePayout('1', '2026-12-25')]; + const conflicts = analyzeConflicts('1', '2026-12-25', payouts, lockups, redemptions); + expect(conflicts).toHaveLength(0); }); }); diff --git a/src/components/PayoutTimeline/conflictAnalysis.ts b/src/components/PayoutTimeline/conflictAnalysis.ts index a5666ee..0a78b25 100644 --- a/src/components/PayoutTimeline/conflictAnalysis.ts +++ b/src/components/PayoutTimeline/conflictAnalysis.ts @@ -1,60 +1,145 @@ /** - * Payout Reschedule Conflict Analysis (Issue #220) + * Payout Reschedule Conflict Analysis (Issue #443) * - * Analyzes potential rescheduling conflicts between payout events. + * Enhanced with lockup period, redemption window, and multi-payout conflict detection. */ import { PayoutEvent } from './PayoutTimeline'; -export type ConflictSeverity = 'hard' | 'soft'; +export type ConflictSeverity = 'hard' | 'soft' | 'info'; export interface Conflict { id: string; severity: ConflictSeverity; message: string; + suggestion?: string; +} + +interface LockupPeriod { + start: string; + end: string; + label: string; +} + +interface RedemptionWindow { + date: string; + label: string; } /** * Analyzes conflicts for a payout being rescheduled. * - * - Hard conflict: Another payout scheduled on the exact same date. - * - Soft conflict: Payout rescheduled to be within 7 days of a 'processing' payout. + * Conflict types: + * - Hard: exact date collision, lockup period overlap, redemption window conflict + * - Soft: within 7 days of processing payout, near lockup boundary + * - Info: multiple payouts clustered, redemption window proximity */ export function analyzeConflicts( payoutId: string, newDateIso: string, allPayouts: PayoutEvent[], + lockupPeriods: LockupPeriod[] = [], + redemptionWindows: RedemptionWindow[] = [], ): Conflict[] { const conflicts: Conflict[] = []; const otherPayouts = allPayouts.filter((p) => p.id !== payoutId); + const newDate = new Date(newDateIso).getTime(); + const MS_PER_DAY = 86_400_000; + const SEVEN_DAYS = 7 * MS_PER_DAY; - // Check for Hard Conflicts (Same Date) + // ── Hard: Exact Date Collision ── const exactMatch = otherPayouts.find((p) => p.date === newDateIso); if (exactMatch) { conflicts.push({ - id: `hard-${exactMatch.id}`, + id: `hard-date-${exactMatch.id}`, severity: 'hard', message: `Conflict: Another payout ("${exactMatch.label}") is already scheduled for ${newDateIso}.`, + suggestion: 'Choose a different date or cancel the conflicting payout first.', }); } - // Check for Soft Conflicts (Within 7 days of 'processing') - const newDate = new Date(newDateIso).getTime(); - const MS_PER_DAY = 86_400_000; - const SEVEN_DAYS = 7 * MS_PER_DAY; + // ── Hard: Lockup Period Overlap ── + lockupPeriods.forEach((lockup) => { + const lockupStart = new Date(lockup.start).getTime(); + const lockupEnd = new Date(lockup.end).getTime(); + if (newDate >= lockupStart && newDate <= lockupEnd) { + conflicts.push({ + id: `hard-lockup-${lockup.label}`, + severity: 'hard', + message: `Conflict: Payout date falls within lockup period "${lockup.label}" (${lockup.start} → ${lockup.end}).`, + suggestion: `Schedule after ${lockup.end} or request lockup override.`, + }); + } + }); + + // ── Hard: Redemption Window Conflict ── + redemptionWindows.forEach((rw) => { + const rwDate = new Date(rw.date).getTime(); + if (newDateIso === rw.date) { + conflicts.push({ + id: `hard-redemption-${rw.label}`, + severity: 'hard', + message: `Conflict: Payout date coincides with redemption window "${rw.label}".`, + suggestion: 'Shift payout by at least 2 business days from redemption date.', + }); + } + }); + // ── Soft: Within 7 days of processing payout ── otherPayouts.forEach((p) => { if (p.status === 'processing') { const pDate = new Date(p.date).getTime(); if (Math.abs(newDate - pDate) <= SEVEN_DAYS) { conflicts.push({ - id: `soft-${p.id}`, + id: `soft-proximity-${p.id}`, severity: 'soft', message: `Warning: Payout is scheduled within 7 days of a processing event ("${p.label}").`, + suggestion: 'Consider spacing payouts by at least 7 days for processing headroom.', }); } } }); + // ── Soft: Near Lockup Boundary ── + lockupPeriods.forEach((lockup) => { + const lockupEnd = new Date(lockup.end).getTime(); + const daysFromLockup = Math.abs(newDate - lockupEnd) / MS_PER_DAY; + if (daysFromLockup <= 3 && daysFromLockup > 0) { + conflicts.push({ + id: `soft-lockup-boundary-${lockup.label}`, + severity: 'soft', + message: `Warning: Payout is within 3 days of lockup period "${lockup.label}" ending.`, + suggestion: 'Verify funds are fully released before payout execution.', + }); + } + }); + + // ── Info: Multiple Payouts Clustered ── + const sameDay = otherPayouts.filter((p) => p.date === newDateIso); + if (sameDay.length >= 2) { + conflicts.push({ + id: 'info-cluster', + severity: 'info', + message: `Notice: ${sameDay.length + 1} payouts are scheduled for ${newDateIso}.`, + suggestion: 'Large clusters may impact gas fees and processing time.', + }); + } + + // ── Info: Redemption Window Proximity ── + redemptionWindows.forEach((rw) => { + const rwDate = new Date(rw.date).getTime(); + const daysDiff = Math.abs(newDate - rwDate) / MS_PER_DAY; + if (daysDiff <= 5 && daysDiff > 0) { + conflicts.push({ + id: `info-redemption-near-${rw.label}`, + severity: 'info', + message: `Notice: Payout is within 5 days of redemption window "${rw.label}".`, + suggestion: 'Ensure sufficient liquidity for both redemption and payout.', + }); + } + }); + return conflicts; } + +export type { LockupPeriod, RedemptionWindow };