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};
+};