From a9220e2922267e2258693db276360aaa4917c4e9 Mon Sep 17 00:00:00 2001 From: Icahbod Date: Mon, 1 Jun 2026 12:41:51 +0000 Subject: [PATCH] refactor: break down complex logic in RecentlyViewed component (#305) - Extract formatTimeAgo helper function for testability - Extract PropertyCard sub-component with clear props interface - Simplify RecentlyViewed main component to use extracted helpers - Add comprehensive unit tests (14 tests) covering formatTimeAgo, PropertyCard, and RecentlyViewed --- src/components/RecentlyViewed.tsx | 141 +++++++----- .../__tests__/RecentlyViewed.test.tsx | 203 ++++++++++++++++++ 2 files changed, 293 insertions(+), 51 deletions(-) create mode 100644 src/components/__tests__/RecentlyViewed.test.tsx diff --git a/src/components/RecentlyViewed.tsx b/src/components/RecentlyViewed.tsx index 66e22056..01740b17 100644 --- a/src/components/RecentlyViewed.tsx +++ b/src/components/RecentlyViewed.tsx @@ -5,26 +5,99 @@ import Image from 'next/image'; import Link from 'next/link'; import { Clock, Trash2, X } from 'lucide-react'; import { useRecentlyViewedStore } from '@/store/recentlyViewedStore'; +import type { RecentlyViewedProperty } from '@/store/recentlyViewedStore'; import { formatPrice } from '@/utils/searchUtils'; -export const RecentlyViewed: React.FC = () => { - const { properties, removeProperty, clearHistory } = useRecentlyViewedStore(); +// ============================================================================ +// Helper functions (extracted for testability) +// ============================================================================ - if (properties.length === 0) return null; +/** + * Formats a timestamp into a human-readable relative time string. + * @param timestamp - Unix timestamp in milliseconds + * @returns A string like "Just now", "5m ago", "3h ago", or "2d ago" + */ +export const formatTimeAgo = (timestamp: number): string => { + const now = Date.now(); + const diffMs = now - timestamp; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +}; - const formatTimeAgo = (timestamp: number) => { - const now = Date.now(); - const diffMs = now - timestamp; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); +// ============================================================================ +// Sub-components +// ============================================================================ - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; +interface PropertyCardProps { + property: RecentlyViewedProperty; + onRemove: (id: string) => void; +} + +/** + * Individual property card for the recently viewed list. + */ +export const PropertyCard: React.FC = ({ property, onRemove }) => { + const handleRemove = (e: React.MouseEvent) => { + e.preventDefault(); + onRemove(property.id); }; + return ( +
+ +
+ {property.name} +
+
+

+ {property.name} +

+

+ {property.location} +

+

+ {formatPrice(property.price)} +

+

+ {formatTimeAgo(property.viewedAt)} +

+
+ + +
+ ); +}; + +// ============================================================================ +// Main component +// ============================================================================ + +/** + * RecentlyViewed displays the user's recently viewed properties. + * Returns null when there are no properties in the history. + */ +export const RecentlyViewed: React.FC = () => { + const { properties, removeProperty, clearHistory } = useRecentlyViewedStore(); + + if (properties.length === 0) return null; + return (
@@ -45,45 +118,11 @@ export const RecentlyViewed: React.FC = () => {
{properties.map((property) => ( -
- -
- {property.name} -
-
-

- {property.name} -

-

- {property.location} -

-

- {formatPrice(property.price)} -

-

- {formatTimeAgo(property.viewedAt)} -

-
- - -
+ property={property} + onRemove={removeProperty} + /> ))}
diff --git a/src/components/__tests__/RecentlyViewed.test.tsx b/src/components/__tests__/RecentlyViewed.test.tsx new file mode 100644 index 00000000..ce42fedb --- /dev/null +++ b/src/components/__tests__/RecentlyViewed.test.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RecentlyViewed, PropertyCard, formatTimeAgo } from '@/components/RecentlyViewed'; +import type { RecentlyViewedProperty } from '@/store/recentlyViewedStore'; + +// Mock next/image +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes & { fill?: boolean }) => { + const { fill, ...rest } = props; + return ; + }, +})); + +// Mock next/link +jest.mock('next/link', () => { + // eslint-disable-next-line react/display-name + return ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ); +}); + +// Mock the recently viewed store +jest.mock('@/store/recentlyViewedStore', () => ({ + useRecentlyViewedStore: jest.fn(), +})); + +const mockUseRecentlyViewedStore = jest.requireMock('@/store/recentlyViewedStore').useRecentlyViewedStore; + +const createMockProperty = (overrides: Partial = {}): RecentlyViewedProperty => ({ + id: 'prop-1', + name: 'Test Property', + location: 'New York, NY', + price: 500000, + image: '/test-image.jpg', + viewedAt: Date.now() - 3600000, // 1 hour ago by default + ...overrides, +}); + +describe('formatTimeAgo', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns "Just now" for timestamps less than 1 minute ago', () => { + const now = Date.now(); + expect(formatTimeAgo(now)).toBe('Just now'); + expect(formatTimeAgo(now - 30000)).toBe('Just now'); // 30 seconds + expect(formatTimeAgo(now - 59000)).toBe('Just now'); // 59 seconds + }); + + it('returns minutes ago for timestamps between 1 and 59 minutes', () => { + const now = Date.now(); + expect(formatTimeAgo(now - 60000)).toBe('1m ago'); // 1 minute + expect(formatTimeAgo(now - 300000)).toBe('5m ago'); // 5 minutes + expect(formatTimeAgo(now - 3540000)).toBe('59m ago'); // 59 minutes + }); + + it('returns hours ago for timestamps between 1 and 23 hours', () => { + const now = Date.now(); + expect(formatTimeAgo(now - 3600000)).toBe('1h ago'); // 1 hour + expect(formatTimeAgo(now - 7200000)).toBe('2h ago'); // 2 hours + expect(formatTimeAgo(now - 82800000)).toBe('23h ago'); // 23 hours + }); + + it('returns days ago for timestamps 24 hours or more', () => { + const now = Date.now(); + expect(formatTimeAgo(now - 86400000)).toBe('1d ago'); // 1 day + expect(formatTimeAgo(now - 172800000)).toBe('2d ago'); // 2 days + expect(formatTimeAgo(now - 864000000)).toBe('10d ago'); // 10 days + }); + + it('handles future timestamps', () => { + const now = Date.now(); + expect(formatTimeAgo(now + 3600000)).toBe('Just now'); // 1 hour in future + }); +}); + +describe('PropertyCard', () => { + const onRemove = jest.fn(); + + beforeEach(() => { + onRemove.mockClear(); + }); + + it('renders property information correctly', () => { + const property = createMockProperty(); + render(); + + expect(screen.getByText('Test Property')).toBeInTheDocument(); + expect(screen.getByText('New York, NY')).toBeInTheDocument(); + expect(screen.getByText('$500,000')).toBeInTheDocument(); + }); + + it('renders the property image', () => { + const property = createMockProperty(); + render(); + + const img = screen.getByAltText('Test Property'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', '/test-image.jpg'); + }); + + it('links to the property detail page', () => { + const property = createMockProperty(); + render(); + + const link = screen.getByText('Test Property').closest('a'); + expect(link).toHaveAttribute('href', '/properties/prop-1'); + }); + + it('calls onRemove when remove button is clicked', () => { + const property = createMockProperty(); + render(); + + const removeButton = screen.getByTitle('Remove from history'); + fireEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalledWith('prop-1'); + }); + + it('shows relative time for viewedAt', () => { + const property = createMockProperty({ viewedAt: Date.now() - 7200000 }); + render(); + + expect(screen.getByText('2h ago')).toBeInTheDocument(); + }); +}); + +describe('RecentlyViewed', () => { + beforeEach(() => { + mockUseRecentlyViewedStore.mockReset(); + }); + + it('renders nothing when there are no properties', () => { + mockUseRecentlyViewedStore.mockReturnValue({ + properties: [], + removeProperty: jest.fn(), + clearHistory: jest.fn(), + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a list of recently viewed properties', () => { + const properties = [ + createMockProperty({ id: 'prop-1', name: 'Property 1' }), + createMockProperty({ id: 'prop-2', name: 'Property 2' }), + ]; + + mockUseRecentlyViewedStore.mockReturnValue({ + properties, + removeProperty: jest.fn(), + clearHistory: jest.fn(), + }); + + render(); + + expect(screen.getByText('Recently Viewed')).toBeInTheDocument(); + expect(screen.getByText('Property 1')).toBeInTheDocument(); + expect(screen.getByText('Property 2')).toBeInTheDocument(); + }); + + it('calls clearHistory when Clear All button is clicked', () => { + const clearHistory = jest.fn(); + const properties = [createMockProperty()]; + + mockUseRecentlyViewedStore.mockReturnValue({ + properties, + removeProperty: jest.fn(), + clearHistory, + }); + + render(); + + fireEvent.click(screen.getByText('Clear All')); + expect(clearHistory).toHaveBeenCalled(); + }); + + it('renders property cards with remove functionality', () => { + const removeProperty = jest.fn(); + const properties = [createMockProperty({ id: 'prop-1' })]; + + mockUseRecentlyViewedStore.mockReturnValue({ + properties, + removeProperty, + clearHistory: jest.fn(), + }); + + render(); + + const removeButton = screen.getByTitle('Remove from history'); + fireEvent.click(removeButton); + + expect(removeProperty).toHaveBeenCalledWith('prop-1'); + }); +});