From e2967e197b4f76267f6a60a626b2a01cb81ccb8c Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Mon, 30 Mar 2026 11:01:46 +0100 Subject: [PATCH 1/3] feat: Add RelativeDate component to display dates as relative text with auto-updating functionality --- package-lock.json | 11 ++ package.json | 3 +- src/components/ui/RelativeDate.test.tsx | 133 ++++++++++++++++++++++++ src/components/ui/RelativeDate.tsx | 42 ++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/RelativeDate.test.tsx create mode 100644 src/components/ui/RelativeDate.tsx diff --git a/package-lock.json b/package-lock.json index 7cf84b7..c3845f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,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", @@ -2917,6 +2918,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 380a603..509eb91 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,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", @@ -46,4 +47,4 @@ "public" ] } -} \ No newline at end of file +} diff --git a/src/components/ui/RelativeDate.test.tsx b/src/components/ui/RelativeDate.test.tsx new file mode 100644 index 0000000..5a59b59 --- /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(global, '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(global, '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 = global.setInterval; + const setIntervalSpy = vi.spyOn(global, '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}; +}; From 60c993f677ab7b4801e7dea3f535ecc698c1c3c7 Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Fri, 3 Apr 2026 11:48:22 +0100 Subject: [PATCH 2/3] test: add unit tests for RelativeDate component functionality and lifecycle management --- src/components/ui/RelativeDate.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ui/RelativeDate.test.tsx b/src/components/ui/RelativeDate.test.tsx index 5a59b59..2819738 100644 --- a/src/components/ui/RelativeDate.test.tsx +++ b/src/components/ui/RelativeDate.test.tsx @@ -44,7 +44,7 @@ describe('RelativeDate', () => { }); it('sets up interval with 60000ms duration', async () => { - const setIntervalSpy = vi.spyOn(global, 'setInterval'); + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); const testDate = new Date(Date.now() - 60 * 1000); render(); @@ -58,7 +58,7 @@ describe('RelativeDate', () => { }); it('cleans up interval on unmount', async () => { - const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); const testDate = new Date(Date.now() - 60 * 1000); const { unmount } = render(); @@ -110,9 +110,9 @@ describe('RelativeDate', () => { let capturedCallback: (() => void) | null = null; // Spy on setInterval to capture the callback - const originalSetInterval = global.setInterval; - const setIntervalSpy = vi.spyOn(global, 'setInterval') - .mockImplementation((callback: any, ms: number) => { + const originalSetInterval = globalThis.setInterval; + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + .mockImplementation((callback: any, ms?: number) => { capturedCallback = callback; return originalSetInterval(callback, ms); }); From b57d2a837e539fb505c2e54889793d999b759d02 Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Fri, 3 Apr 2026 12:02:19 +0100 Subject: [PATCH 3/3] ci config --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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