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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions src/components/ui/RelativeDate.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RelativeDate date={pastDate} />);

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(<RelativeDate date={pastDate.toISOString()} />);

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(<RelativeDate date={testDate} />);

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(<RelativeDate date={testDate} />);

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(<RelativeDate date={testDate} />);

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(<RelativeDate date={testDate} />);

await waitFor(() => {
expect(screen.getByText(/2 hours? ago/)).toBeInTheDocument();
});

const firstText = screen.getByText(/ago/).textContent;

rerender(<RelativeDate date={dateString} />);

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(<RelativeDate date={testDate} />);

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(<RelativeDate date={testDate} />);

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();
});
});
42 changes: 42 additions & 0 deletions src/components/ui/RelativeDate.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('');

// Convert string to Date if needed
const dateObj = typeof date === 'string' ? new Date(date) : date;

Check warning on line 16 in src/components/ui/RelativeDate.tsx

View workflow job for this annotation

GitHub Actions / validate

The 'dateObj' conditional could make the dependencies of useEffect Hook (at line 35) change on every render. To fix this, wrap the initialization of 'dateObj' in its own useMemo() Hook

// 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 <span title={absoluteDate}>Loading...</span>;
}

return <span title={absoluteDate}>{relativeText}</span>;
};
Loading