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
89 changes: 65 additions & 24 deletions src/components/ui/DocumentExpiryBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,72 @@
import { AlertCircle } from 'lucide-react'

interface DocumentExpiryBadgeProps {
expiresAt: string | null;
expiresAt: string | null
status: string
}

export function DocumentExpiryBadge({ expiresAt }: DocumentExpiryBadgeProps) {
if (!expiresAt) return null;

const now = new Date();
const expiry = new Date(expiresAt);
const msUntilExpiry = expiry.getTime() - now.getTime();
const daysUntilExpiry = msUntilExpiry / (1000 * 60 * 60 * 24);

const config =
daysUntilExpiry < 0
? { label: 'Expired', textClass: 'text-red-700', bgClass: 'bg-red-100' }
: daysUntilExpiry <= 30
? { label: 'Expiring soon', textClass: 'text-amber-700', bgClass: 'bg-amber-100' }
: {
label: `Expires ${expiry.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`,
textClass: 'text-blue-700',
bgClass: 'bg-blue-100',
};
// 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 (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${config.textClass} ${config.bgClass}`}
>
{config.label}
</span>
);
<div className="group relative inline-flex items-center gap-2">
<div
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${badgeConfig.bgClass} ${badgeConfig.textClass}`}
>
<AlertCircle className="h-3.5 w-3.5" />
<span>{badgeConfig.label}</span>
</div>

{/* Tooltip */}
<div className="absolute left-1/2 top-full z-10 mt-2 w-max -translate-x-1/2 scale-95 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100">
<div className="rounded-md bg-gray-900 px-3 py-2 text-xs text-white shadow-lg">
{badgeConfig.tooltip}
</div>
</div>
</div>
)
}
227 changes: 227 additions & 0 deletions src/components/ui/__tests__/DocumentExpiryBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DocumentExpiryBadge expiresAt={null} status="test" />
)
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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

// 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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

// 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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={expiryDate.toISOString()}
status="test"
/>
)

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(
<DocumentExpiryBadge
expiresAt={new Date('2026-03-30').toISOString()}
status="test"
/>
)
expect(screen.getByText(/Expiring in 1 day/)).toBeInTheDocument()
unmount1()

// Test with 2 days
render(
<DocumentExpiryBadge
expiresAt={new Date('2026-03-31').toISOString()}
status="test"
/>
)
expect(screen.getByText(/Expiring in 2 days/)).toBeInTheDocument()
})

it('accepts ISO string format for expiresAt', () => {
const isoString = '2026-04-01T10:30:00Z'
render(
<DocumentExpiryBadge expiresAt={isoString} status="test" />
)

const badge = screen.getByText(/Expiring in 3 days/)
expect(badge).toBeInTheDocument()
})
})
})
17 changes: 17 additions & 0 deletions src/mocks/handlers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface Document {
adoptionId: string;
createdAt: string;
updatedAt: string;
expiresAt?: string | null;
}

interface UploadDocumentsResponse {
Expand All @@ -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",
Expand All @@ -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",
},
];

Expand Down Expand Up @@ -81,6 +97,7 @@ export const filesHandlers = [
adoptionId: params.id as string,
createdAt: now,
updatedAt: now,
expiresAt: null,
};

return HttpResponse.json<UploadDocumentsResponse>({
Expand Down
Loading