diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3be4e92..085c044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm install + run: npm install --maxsockets 1 --retry 3 - name: Lint check run: npm run lint diff --git a/package-lock.json b/package-lock.json index f6f9c3e..7b16db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.95.2", + "date-fns": "^4.1.0", "lucide-react": "^0.575.0", "react": "^19.2.4", "react-copy-to-clipboard": "^5.1.1", @@ -2908,6 +2909,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "dev": true, diff --git a/package.json b/package.json index 13a6aff..5ff35a3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.95.2", + "date-fns": "^4.1.0", "lucide-react": "^0.575.0", "react": "^19.2.4", "react-copy-to-clipboard": "^5.1.1", diff --git a/src/components/ui/RelativeDate.test.tsx b/src/components/ui/RelativeDate.test.tsx new file mode 100644 index 0000000..2819738 --- /dev/null +++ b/src/components/ui/RelativeDate.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { RelativeDate } from './RelativeDate'; + +describe('RelativeDate', () => { + beforeEach(() => { + vi.clearAllTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correct initial output with Date object', async () => { + const pastDate = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago + render(); + + await waitFor(() => { + const spanElement = screen.queryAllByText(/ago/)[0]; + expect(spanElement).toBeInTheDocument(); + expect(spanElement?.textContent).toMatch(/2 hours? ago/); + }); + }); + + it('renders correct initial output with string date', async () => { + const pastDate = new Date(Date.now() - 30 * 60 * 1000); // 30 minutes ago + render(); + + await waitFor(() => { + const spanElement = screen.queryAllByText(/ago/)[0]; + expect(spanElement).toBeInTheDocument(); + expect(spanElement?.textContent).toMatch(/30 minutes? ago/); + }); + }); + + it('shows absolute date in title attribute on hover', async () => { + const testDate = new Date('2025-01-15T10:30:00'); + render(); + + await waitFor(() => { + const element = screen.getByTitle(testDate.toLocaleString()); + expect(element).toBeInTheDocument(); + }); + }); + + it('sets up interval with 60000ms duration', async () => { + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const testDate = new Date(Date.now() - 60 * 1000); + + render(); + + await waitFor(() => { + expect(screen.getByText(/ago/)).toBeInTheDocument(); + }); + + // Verify setInterval was called with 60000ms (60 seconds) + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 60000); + }); + + it('cleans up interval on unmount', async () => { + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + const testDate = new Date(Date.now() - 60 * 1000); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByText(/ago/)).toBeInTheDocument(); + }); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + + it('handles both Date and string inputs consistently', async () => { + const testDate = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago + const dateString = testDate.toISOString(); + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByText(/2 hours? ago/)).toBeInTheDocument(); + }); + + const firstText = screen.getByText(/ago/).textContent; + + rerender(); + + await waitFor(() => { + expect(screen.getByText(/2 hours? ago/)).toBeInTheDocument(); + }); + + const secondText = screen.getByText(/ago/).textContent; + expect(firstText).toBe(secondText); + }); + + it('displays title attribute with absolute date for accessibility', async () => { + const testDate = new Date('2024-12-25T15:45:30'); + render(); + + await waitFor(() => { + const span = screen.getByTitle(testDate.toLocaleString()); + expect(span).toBeInTheDocument(); + expect(span).toHaveAttribute('title', testDate.toLocaleString()); + }); + }); + + it('updates on interval by verifying setInterval callback execution', async () => { + const testDate = new Date(Date.now() - 1 * 60 * 1000); // 1 minute ago + let capturedCallback: (() => void) | null = null; + + // Spy on setInterval to capture the callback + const originalSetInterval = globalThis.setInterval; + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + .mockImplementation((callback: any, ms?: number) => { + capturedCallback = callback; + return originalSetInterval(callback, ms); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/ago/)).toBeInTheDocument(); + }); + + // Verify the interval was set up with 60000ms + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 60000); + // Verify we captured the callback + expect(capturedCallback).toBeTruthy(); + + setIntervalSpy.mockRestore(); + }); +}); diff --git a/src/components/ui/RelativeDate.tsx b/src/components/ui/RelativeDate.tsx new file mode 100644 index 0000000..555eded --- /dev/null +++ b/src/components/ui/RelativeDate.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; + +interface RelativeDateProps { + date: Date | string; +} + +/** + * Displays a date as relative text (e.g., "2 hours ago") that updates every 60 seconds. + * Shows the absolute date on hover via title attribute. + */ +export const RelativeDate = ({ date }: RelativeDateProps) => { + const [relativeText, setRelativeText] = useState(''); + + // Convert string to Date if needed + const dateObj = typeof date === 'string' ? new Date(date) : date; + + // Format absolute date for title attribute (locale-aware) + const absoluteDate = dateObj.toLocaleString(); + + useEffect(() => { + const updateRelativeDate = () => { + const formatted = formatDistanceToNow(dateObj, { addSuffix: true }); + setRelativeText(formatted); + }; + + // Initial update + updateRelativeDate(); + + // Set up interval to update every 60 seconds + const interval = setInterval(updateRelativeDate, 60000); + + // Cleanup interval on unmount + return () => clearInterval(interval); + }, [dateObj, absoluteDate]); + + if (!relativeText) { + return Loading...; + } + + return {relativeText}; +};