From 1798b6f48d261ad99edb4a6c4bf8265c7a9eaa2a Mon Sep 17 00:00:00 2001 From: ayomisco Date: Sun, 29 Mar 2026 20:37:32 +0100 Subject: [PATCH] feat(ui): create DocumentExpiryBadge component for file verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DocumentExpiryBadge component showing document expiry status - null: no badge, within 7 days: amber 'Expiring in X days', expired: red 'Expired — re-upload required' - Includes AlertCircle icon and hover tooltip with exact expiry date - Add 15 comprehensive unit tests covering all thresholds - Update Document mock type to include optional expiresAt field - All checks: 340/340 tests passing, lint clean, TypeScript strict Closes #259 --- src/components/ui/DocumentExpiryBadge.tsx | 72 ++++++ .../ui/__tests__/DocumentExpiryBadge.test.tsx | 227 ++++++++++++++++++ src/mocks/handlers/files.ts | 17 ++ 3 files changed, 316 insertions(+) create mode 100644 src/components/ui/DocumentExpiryBadge.tsx create mode 100644 src/components/ui/__tests__/DocumentExpiryBadge.test.tsx diff --git a/src/components/ui/DocumentExpiryBadge.tsx b/src/components/ui/DocumentExpiryBadge.tsx new file mode 100644 index 0000000..be7d4cd --- /dev/null +++ b/src/components/ui/DocumentExpiryBadge.tsx @@ -0,0 +1,72 @@ +import { AlertCircle } from 'lucide-react' + +interface DocumentExpiryBadgeProps { + expiresAt: string | null + status: string +} + +export function DocumentExpiryBadge({ expiresAt }: DocumentExpiryBadgeProps) { + // No badge if expiresAt is null + if (!expiresAt) { + return null + } + + const expiryDate = new Date(expiresAt) + // Reset expiry time to midnight for consistent day comparison + expiryDate.setHours(0, 0, 0, 0) + + const today = new Date() + today.setHours(0, 0, 0, 0) + + const daysUntilExpiry = Math.ceil( + (expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ) + + // Determine badge state and styling + let badgeConfig: { + label: string + bgClass: string + textClass: string + tooltip: string + } + + if (daysUntilExpiry < 0) { + // Expired + badgeConfig = { + label: 'Expired — re-upload required', + bgClass: 'bg-red-100', + textClass: 'text-red-700', + tooltip: `Expired on ${expiryDate.toLocaleDateString()}`, + } + } else if (daysUntilExpiry <= 7) { + // Expiring soon (within 7 days) + const dayLabel = daysUntilExpiry === 1 ? 'day' : 'days' + badgeConfig = { + label: `Expiring in ${daysUntilExpiry} ${dayLabel}`, + bgClass: 'bg-amber-100', + textClass: 'text-amber-700', + tooltip: `Expires on ${expiryDate.toLocaleDateString()}`, + } + } else { + // Not expiring soon - no badge + return null + } + + return ( +
+
+ + {badgeConfig.label} +
+ + {/* Tooltip */} +
+
+ {badgeConfig.tooltip} +
+
+
+ ) +} diff --git a/src/components/ui/__tests__/DocumentExpiryBadge.test.tsx b/src/components/ui/__tests__/DocumentExpiryBadge.test.tsx new file mode 100644 index 0000000..9e72975 --- /dev/null +++ b/src/components/ui/__tests__/DocumentExpiryBadge.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { DocumentExpiryBadge } from '../DocumentExpiryBadge' + +// Mock current date to 2026-03-29 for consistent testing +const MOCK_TODAY = new Date('2026-03-29') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(MOCK_TODAY) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('DocumentExpiryBadge', () => { + describe('null expiresAt', () => { + it('renders nothing when expiresAt is null', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + }) + + describe('amber threshold (within 7 days)', () => { + it('shows amber badge for document expiring in 7 days', () => { + const expiryDate = new Date('2026-04-05') // 7 days from now + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-amber-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-amber-700') + }) + + it('shows amber badge for document expiring in 1 day', () => { + const expiryDate = new Date('2026-03-30') // 1 day from now + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-amber-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-amber-700') + }) + + it('shows amber badge for document expiring in 3 days', () => { + const expiryDate = new Date('2026-04-01') // 3 days from now + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-amber-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-amber-700') + }) + + it('includes AlertCircle icon in amber badge', () => { + const expiryDate = new Date('2026-04-01') + const { container } = render( + + ) + + // Lucide icons render as SVG + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('shows correct tooltip for amber badge', () => { + const expiryDate = new Date('2026-04-01') + const { container } = render( + + ) + + // Tooltip text should contain the expiry date + const tooltip = container.querySelector('.group-hover\\:opacity-100') + expect(tooltip?.textContent).toContain('4/1/2026') + }) + }) + + describe('red threshold (expired)', () => { + it('shows red badge for expired document', () => { + const expiryDate = new Date('2026-03-28') // 1 day ago + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-red-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-red-700') + }) + + it('shows red badge for document expired months ago', () => { + const expiryDate = new Date('2025-12-25') // Months ago + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-red-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-red-700') + }) + + it('includes AlertCircle icon in red badge', () => { + const expiryDate = new Date('2026-03-28') + const { container } = render( + + ) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('shows correct tooltip for red badge', () => { + const expiryDate = new Date('2026-03-15') + const { container } = render( + + ) + + const tooltip = container.querySelector('.group-hover\\:opacity-100') + expect(tooltip?.textContent).toContain('3/15/2026') + }) + }) + + describe('no badge states', () => { + it('renders nothing for document expiring in 8+ days', () => { + const expiryDate = new Date('2026-04-10') // 12 days from now + const { container } = render( + + ) + + expect(container.firstChild).toBeNull() + }) + + it('renders nothing for document expiring far in future', () => { + const expiryDate = new Date('2027-12-31') // Almost 2 years away + const { container } = render( + + ) + + expect(container.firstChild).toBeNull() + }) + }) + + describe('edge cases', () => { + it('handles document expiring today (0 days)', () => { + const expiryDate = new Date('2026-03-29') // Today + const { container } = render( + + ) + + const badgeContainer = container.querySelector('.bg-amber-100') + expect(badgeContainer).toBeInTheDocument() + expect(badgeContainer).toHaveClass('text-amber-700') + }) + + it('correctly pluralizes "day" vs "days"', () => { + // Test with 1 day + const { unmount: unmount1 } = render( + + ) + expect(screen.getByText(/Expiring in 1 day/)).toBeInTheDocument() + unmount1() + + // Test with 2 days + render( + + ) + expect(screen.getByText(/Expiring in 2 days/)).toBeInTheDocument() + }) + + it('accepts ISO string format for expiresAt', () => { + const isoString = '2026-04-01T10:30:00Z' + render( + + ) + + const badge = screen.getByText(/Expiring in 3 days/) + expect(badge).toBeInTheDocument() + }) + }) +}) diff --git a/src/mocks/handlers/files.ts b/src/mocks/handlers/files.ts index 9a3bc8a..c420ee8 100644 --- a/src/mocks/handlers/files.ts +++ b/src/mocks/handlers/files.ts @@ -15,6 +15,7 @@ interface Document { adoptionId: string; createdAt: string; updatedAt: string; + expiresAt?: string | null; } interface UploadDocumentsResponse { @@ -36,6 +37,7 @@ const MOCK_DOCUMENTS: Document[] = [ adoptionId: "adoption-001", createdAt: "2026-03-18T08:00:00.000Z", updatedAt: "2026-03-18T08:00:00.000Z", + expiresAt: null, }, { id: "doc-002", @@ -48,6 +50,20 @@ const MOCK_DOCUMENTS: Document[] = [ adoptionId: "adoption-001", createdAt: "2026-03-18T08:30:00.000Z", updatedAt: "2026-03-18T08:30:00.000Z", + expiresAt: "2026-04-01T23:59:59.000Z", + }, + { + id: "doc-003", + fileName: "proof-of-residence.pdf", + fileUrl: "https://res.cloudinary.com/petad/image/upload/v1/adoptions/adoption-001/proof-of-residence.pdf", + publicId: "adoptions/adoption-001/proof-of-residence", + mimeType: "application/pdf", + size: 153600, + uploadedById: "user-owner-1", + adoptionId: "adoption-001", + createdAt: "2026-03-18T09:00:00.000Z", + updatedAt: "2026-03-18T09:00:00.000Z", + expiresAt: "2026-03-15T23:59:59.000Z", }, ]; @@ -81,6 +97,7 @@ export const filesHandlers = [ adoptionId: params.id as string, createdAt: now, updatedAt: now, + expiresAt: null, }; return HttpResponse.json({