From ced9c8555a4d925a25de1104d9194b97fe44aba8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:45:17 +0000 Subject: [PATCH 1/5] feature: port Angular user profile page to React UserPage Co-Authored-By: Vibha Seshadri --- web/src/pages/UserPage.tsx | 71 ++++++++++++++++++++-- web/src/user/UserPage.test.tsx | 105 +++++++++++++++++++++++++++++++++ web/src/user/user.scss | 89 ++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 web/src/user/UserPage.test.tsx create mode 100644 web/src/user/user.scss diff --git a/web/src/pages/UserPage.tsx b/web/src/pages/UserPage.tsx index 89348c35..9a550607 100644 --- a/web/src/pages/UserPage.tsx +++ b/web/src/pages/UserPage.tsx @@ -1,9 +1,70 @@ -/** - * Placeholder for the ported `UserComponent`, implemented in Phase 2d. - * The user id comes from the `/user/:id` route via `useParams`. - */ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { fetchUser } from '../api/hackerNews'; +import { ErrorMessage } from '../components/ErrorMessage'; +import { Loader } from '../components/Loader'; +import { User } from '../models/user'; +import '../user/user.scss'; + export function UserPage() { - return null; + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + if (!id) { + return; + } + + let cancelled = false; + setUser(null); + setErrorMessage(''); + + fetchUser(id) + .then((data) => { + if (!cancelled) { + setUser(data); + } + }) + .catch(() => { + if (!cancelled) { + setErrorMessage(`Could not load user ${id}.`); + } + }); + + return () => { + cancelled = true; + }; + }, [id]); + + const goBack = () => navigate(-1); + + if (!user) { + return errorMessage !== '' ? : ; + } + + return ( +
+
+

+ + Profile: {user.id} +

+
+
+ {user.id} + {user.karma} ★ +

Created {user.created}

+
+ {user.about && ( +
+

+
+ )} +
+ ); } export default UserPage; diff --git a/web/src/user/UserPage.test.tsx b/web/src/user/UserPage.test.tsx new file mode 100644 index 00000000..8856fe54 --- /dev/null +++ b/web/src/user/UserPage.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchUser } from '../api/hackerNews'; +import { SettingsProvider } from '../context/SettingsContext'; +import { User } from '../models/user'; +import { stubMatchMedia } from '../testUtils/matchMedia'; +import { UserPage } from '../pages/UserPage'; + +vi.mock('../api/hackerNews', () => ({ + fetchUser: vi.fn(), +})); + +const fetchUserMock = vi.mocked(fetchUser); + +const user: User = { + id: 'pg', + created: '4230 days ago', + karma: 155000, + about: '

Y Combinator

indented
', +}; + +function renderUserPage(entries: string[] = ['/user/pg'], initialIndex = 0) { + return render( + + + + news feed

} /> + } /> +
+
+
+ ); +} + +describe('UserPage', () => { + beforeEach(() => { + stubMatchMedia(false); + fetchUserMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('shows the loader until the user has been fetched', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(); + + expect(container.querySelector('.loading-section .loader')).not.toBeNull(); + expect(await screen.findByText('Created 4230 days ago')).toBeInTheDocument(); + expect(container.querySelector('.loading-section')).toBeNull(); + }); + + it('renders the profile of the fetched user', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(); + + await screen.findByText('Created 4230 days ago'); + + expect(fetchUserMock).toHaveBeenCalledWith('pg'); + expect(screen.getByText('Profile: pg')).toHaveClass('title-block'); + expect(container.querySelector('.mobile.item-header .back-button')).not.toBeNull(); + expect(container.querySelector('.main-details .name')).toHaveTextContent('pg'); + expect(container.querySelector('.main-details .right')).toHaveTextContent('155000 ★'); + expect(container.querySelector('.other-details p')?.innerHTML).toBe('

Y Combinator

indented
'); + }); + + it('omits the about section for a user without an about text', async () => { + fetchUserMock.mockResolvedValue({ id: 'lurker', created: '2 days ago', karma: 1 }); + + const { container } = renderUserPage(['/user/lurker']); + + await screen.findByText('Created 2 days ago'); + + expect(container.querySelector('.other-details')).toBeNull(); + }); + + it('shows an error message when the user could not be loaded', async () => { + fetchUserMock.mockRejectedValue(new Error('offline')); + + const { container } = renderUserPage(['/user/ghost']); + + expect(await screen.findByText('Could not load user ghost.')).toBeInTheDocument(); + expect(container.querySelector('.profile')).toBeNull(); + expect(container.querySelector('.loading-section')).toBeNull(); + }); + + it('goes back in the history when the back button is clicked', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(['/news/1', '/user/pg'], 1); + + await screen.findByText('Created 4230 days ago'); + + await userEvent.click(container.querySelector('.back-button') as HTMLElement); + + await waitFor(() => expect(screen.getByText('news feed')).toBeInTheDocument()); + }); +}); diff --git a/web/src/user/user.scss b/web/src/user/user.scss new file mode 100644 index 00000000..0288468e --- /dev/null +++ b/web/src/user/user.scss @@ -0,0 +1,89 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +.profile pre { + white-space: pre-wrap; +} + +.profile { + padding: 30px; +} + +@media #{$mobile-only} { + .profile { + padding: 110px 15px 0 15px; + } + .title-block { + font-size: 15px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; + } + .back-button { + position: absolute; + top: 52%; + width: 0.6rem; + height: 0.6rem; + background: transparent; + box-shadow: 0 0 0 lightgray; + transition: all 200ms ease; + left: 4%; + transform: translate3d(0, -50%, 0) rotate(-135deg); + } + .item-header { + padding-bottom: 10px; + background-color: #fff; + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + height: 20px; + } +} + +@media #{$laptop-only} { + .mobile { + display: none; + } +} + +.main-details { + .name { + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } + .age { + font-weight: bold; + color: #696969; + padding-bottom: 0; + } + .right { + float: right; + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } +} + +@media #{$mobile-only} { + .main-details { + margin-top: 20px; + .name { + font-size: 18px; + } + } +} + +@media #{$mobile-only} { + .main-details .right { + font-size: 18px; + } +} + +.other-details { + word-wrap: break-word; +} From 4d5c98c5b35aebec6de17cf1006cbb47c868c1d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:45:28 +0000 Subject: [PATCH 2/5] feature: port Angular feed and item components to React (Phase 2b) Co-Authored-By: Vibha Seshadri --- web/src/feeds/Item.test.tsx | 140 +++++++++++++++++++++++++ web/src/feeds/Item.tsx | 82 +++++++++++++++ web/src/feeds/feed.scss | 107 ++++++++++++++++++++ web/src/feeds/item.scss | 68 +++++++++++++ web/src/pages/FeedPage.test.tsx | 174 ++++++++++++++++++++++++++++++++ web/src/pages/FeedPage.tsx | 87 +++++++++++++++- 6 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 web/src/feeds/Item.test.tsx create mode 100644 web/src/feeds/Item.tsx create mode 100644 web/src/feeds/feed.scss create mode 100644 web/src/feeds/item.scss create mode 100644 web/src/pages/FeedPage.test.tsx diff --git a/web/src/feeds/Item.test.tsx b/web/src/feeds/Item.test.tsx new file mode 100644 index 00000000..bd53376e --- /dev/null +++ b/web/src/feeds/Item.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Item } from './Item'; +import { SettingsProvider } from '../context/SettingsContext'; +import { Story } from '../models/story'; +import { stubMatchMedia } from '../testUtils/matchMedia'; + +function makeStory(overrides: Partial = {}): Story { + return { + id: 1, + title: 'A React story', + points: 42, + user: 'dan', + time: 1600000000, + time_ago: '2 hours ago', + type: 'story', + url: 'https://example.com/story', + domain: 'example.com', + comments_count: 3, + ...overrides, + }; +} + +function renderItem(story: Story) { + stubMatchMedia(false); + + return render( + + + + + + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Item', () => { + it('renders an external link with its domain for stories that have a url', () => { + renderItem(makeStory()); + + const title = screen.getByRole('link', { name: 'A React story' }); + expect(title).toHaveClass('title'); + expect(title).toHaveAttribute('href', 'https://example.com/story'); + expect(title).not.toHaveAttribute('target'); + expect(title).not.toHaveAttribute('rel'); + expect(screen.getByText('(example.com)')).toHaveClass('domain'); + }); + + it('links to the item details page for stories without an external url', () => { + renderItem(makeStory({ id: 7, url: 'item?id=7', domain: undefined })); + + const title = screen.getByRole('link', { name: 'A React story' }); + expect(title).toHaveClass('title'); + expect(title).toHaveAttribute('href', '/item/7'); + expect(screen.queryByText(/\(.*\)/)).toBeNull(); + }); + + it('links to the item details page when the story has no url at all', () => { + renderItem(makeStory({ id: 9, url: undefined, domain: undefined })); + + expect(screen.getByRole('link', { name: 'A React story' })).toHaveAttribute('href', '/item/9'); + }); + + it('renders the user, points, time and comment count', () => { + const { container } = renderItem(makeStory({ id: 5, user: 'pg', points: 12, comments_count: 1 })); + + const userLinks = screen.getAllByRole('link', { name: 'pg' }); + expect(userLinks).toHaveLength(2); + userLinks.forEach((link) => expect(link).toHaveAttribute('href', '/user/pg')); + expect(screen.getByText('12 ★')).toBeInTheDocument(); + + const commentLinks = screen.getAllByRole('link', { name: '1 comment' }); + expect(commentLinks).toHaveLength(1); + expect(commentLinks[0]).toHaveAttribute('href', '/item/5'); + expect(screen.getByRole('link', { name: '• 1 comment' })).toHaveClass('comment-number'); + + expect(container.querySelector('.subtext-palm')).toHaveTextContent('2 hours ago • 1 comment'); + expect(container.querySelector('.subtext-laptop')).toHaveTextContent('12 points by pg2 hours ago | 1 comment'); + }); + + it('renders "discuss" when a story has no comments', () => { + const { container } = renderItem(makeStory({ comments_count: 0 })); + + expect(container.querySelector('.subtext-palm')).toHaveTextContent('• discuss'); + expect(container.querySelector('.subtext-laptop')).toHaveTextContent('| discuss'); + }); + + it('omits the user, points and comments for job items', () => { + renderItem(makeStory({ type: 'job', title: 'Work at a startup', comments_count: 0 })); + + expect(screen.queryByRole('link', { name: 'dan' })).toBeNull(); + expect(screen.queryByText('42 ★')).toBeNull(); + expect(screen.queryByRole('link', { name: /discuss/ })).toBeNull(); + expect(screen.getAllByText('2 hours ago')).toHaveLength(2); + }); + + it('does not add the item-details class on the laptop subtext for job items', () => { + const { container } = renderItem(makeStory({ type: 'job' })); + + expect(container.querySelector('.subtext-laptop .item-details')).toBeNull(); + }); + + it('adds the item-details class on the laptop subtext for regular items', () => { + const { container } = renderItem(makeStory()); + + expect(container.querySelector('.subtext-laptop .item-details')).not.toBeNull(); + }); + + it('opens external links in a new tab when the setting is enabled', () => { + localStorage.setItem('openLinkInNewTab', 'true'); + + renderItem(makeStory()); + + const title = screen.getByRole('link', { name: 'A React story' }); + expect(title).toHaveAttribute('target', '_blank'); + expect(title).toHaveAttribute('rel', 'noopener'); + }); + + it('applies the title font size and list spacing settings', () => { + localStorage.setItem('titleFontSize', '20'); + localStorage.setItem('listSpacing', '15'); + + const { container } = renderItem(makeStory()); + + expect(screen.getByRole('link', { name: 'A React story' })).toHaveStyle({ fontSize: '20px' }); + expect(container.querySelector('.item-block > div')).toHaveStyle({ marginBottom: '15px' }); + }); + + it('falls back to the default font size and spacing', () => { + const { container } = renderItem(makeStory()); + + expect(screen.getByRole('link', { name: 'A React story' })).toHaveStyle({ fontSize: '16px' }); + expect(container.querySelector('.item-block > div')).toHaveStyle({ marginBottom: '0px' }); + }); +}); diff --git a/web/src/feeds/Item.tsx b/web/src/feeds/Item.tsx new file mode 100644 index 00000000..3a11668b --- /dev/null +++ b/web/src/feeds/Item.tsx @@ -0,0 +1,82 @@ +import { NavLink } from 'react-router-dom'; + +import { useSettings } from '../context/SettingsContext'; +import { Story } from '../models/story'; +import { formatCommentCount } from '../utils/formatCommentCount'; +import './item.scss'; + +export interface ItemProps { + item: Story; + className?: string; +} + +export function Item({ item, className }: ItemProps) { + const { settings } = useSettings(); + const hasUrl = item.url !== undefined && item.url.indexOf('http') === 0; + const isJob = item.type === 'job'; + const titleStyle = { fontSize: `${settings.titleFontSize}px` }; + + return ( +
+
+ {hasUrl ? ( +

+ + {item.title} + + {item.domain && ({item.domain})} +

+ ) : ( +

+ + {item.title} + +

+ )} +
+ {!isJob && ( +
+ + {item.user} + + {item.points} ★ +
+ )} +
+ {item.time_ago} + {!isJob && ( + + {' '} + • {formatCommentCount(item.comments_count)} + + )} +
+
+
+ {!isJob && ( + + {item.points} points by {item.user} + + )} + + {item.time_ago} + {!isJob && ( + + {' '} + | {formatCommentCount(item.comments_count)} + + )} + +
+
+
+ ); +} + +export default Item; diff --git a/web/src/feeds/feed.scss b/web/src/feeds/feed.scss new file mode 100644 index 00000000..946131da --- /dev/null +++ b/web/src/feeds/feed.scss @@ -0,0 +1,107 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +a { + text-decoration: none; + font-weight: bold; + + &:hover { + text-decoration: underline; + } +} + +ol { + padding: 0 40px; + margin: 0; + + @media #{$mobile-only} { + box-sizing: border-box; + list-style: none; + padding: 0 10px; + } + + li { + position: relative; + -webkit-transition: background-color 0.2s ease; + transition: background-color 0.2s ease; + } +} + +.list-margin { + @media #{$mobile-only} { + margin-top: 55px; + } +} + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity 0.2s ease; + transition: opacity 0.2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; +} + +.post { + padding: 10px 0 10px 5px; + transition: background-color 0.2s ease; + border-bottom: 1px solid #cececb; + + .itemNum { + color: #696969; + position: absolute; + width: 30px; + text-align: right; + left: 0; + top: 4px; + } +} + +.item-block { + display: block; +} + +.nav { + padding: 10px 40px; + margin-top: 10px; + font-size: 17px; + + a { + @media #{$mobile-only} { + text-decoration: none; + } + } + + @media #{$mobile-only} { + margin: 20px 0; + text-align: center; + padding: 10px 80px; + height: 20px; + } + + .prev { + padding-right: 20px; + + @media #{$mobile-only} { + float: left; + padding-right: 0; + } + } + + .more { + @media #{$mobile-only} { + float: right; + } + } +} + +.job-header { + font-size: 15px; + padding: 0 40px 10px; + + @media #{$mobile-only} { + padding: 60px 15px 25px 15px; + } +} diff --git a/web/src/feeds/item.scss b/web/src/feeds/item.scss new file mode 100644 index 00000000..f1acb15b --- /dev/null +++ b/web/src/feeds/item.scss @@ -0,0 +1,68 @@ +@import '../styles/media'; +@import '../styles/theme_variables'; + +p { + margin: 2px 0; + + @media #{$mobile-only} { + margin-bottom: 5px; + margin-top: 0; + } +} + +a { + cursor: pointer; + text-decoration: none; +} + +.title { + font-size: 16px; + font-family: Verdana, Geneva, sans-serif; +} + +.subtext-laptop { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; + + a { + &:hover { + text-decoration: underline; + } + } + @media #{$mobile-only} { + display: none; + } +} + +.subtext-palm { + font-size: 13px; + font-weight: bold; + letter-spacing: 0.5px; + + a { + &:hover { + text-decoration: underline; + } + } + + .details { + margin-top: 5px; + + .right { + float: right; + } + } + @media #{$laptop-only} { + display: none; + } +} + +.domain { + color: #696969; + letter-spacing: 0.5px; +} + +.item-details { + padding: 10px; +} diff --git a/web/src/pages/FeedPage.test.tsx b/web/src/pages/FeedPage.test.tsx new file mode 100644 index 00000000..fe3077b1 --- /dev/null +++ b/web/src/pages/FeedPage.test.tsx @@ -0,0 +1,174 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FeedPage } from './FeedPage'; +import { fetchFeed } from '../api/hackerNews'; +import { SettingsProvider } from '../context/SettingsContext'; +import { Story } from '../models/story'; +import { stubMatchMedia } from '../testUtils/matchMedia'; + +vi.mock('../api/hackerNews'); + +const fetchFeedMock = vi.mocked(fetchFeed); + +function makeStories(count: number, startId = 1): Story[] { + return Array.from({ length: count }, (_, index) => ({ + id: startId + index, + title: `Story ${startId + index}`, + points: 10 + index, + user: `user${startId + index}`, + time: 1600000000, + time_ago: '1 hour ago', + type: 'story' as const, + url: `https://example.com/${startId + index}`, + domain: 'example.com', + comments_count: index, + })); +} + +function renderFeed(feedType = 'news', path = `/${feedType}/1`) { + return render( + + + + } /> + } /> + + + + ); +} + +beforeEach(() => { + stubMatchMedia(false); + vi.stubGlobal('scrollTo', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('FeedPage', () => { + it('shows the loader while the feed is loading', () => { + fetchFeedMock.mockReturnValue(new Promise(() => {})); + + renderFeed(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(fetchFeedMock).toHaveBeenCalledWith('news', 1); + }); + + it('renders the loaded stories and scrolls back to the top', async () => { + fetchFeedMock.mockResolvedValue(makeStories(3)); + + const { container } = renderFeed(); + + expect(await screen.findByText('Story 1')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).toBeNull(); + expect(container.querySelectorAll('li.post')).toHaveLength(3); + expect(container.querySelector('ol')).toHaveAttribute('start', '1'); + expect(container.querySelector('ol')).toHaveClass('list-margin'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('continues the rank numbering on later pages', async () => { + fetchFeedMock.mockResolvedValue(makeStories(30, 31)); + + const { container } = renderFeed('news', '/news/2'); + + await screen.findByText('Story 31'); + expect(fetchFeedMock).toHaveBeenCalledWith('news', 2); + expect(container.querySelector('ol')).toHaveAttribute('start', '31'); + }); + + it('defaults to page one when the route has no page parameter', async () => { + fetchFeedMock.mockResolvedValue(makeStories(1)); + + const { container } = renderFeed('news', '/news'); + + await screen.findByText('Story 1'); + expect(fetchFeedMock).toHaveBeenCalledWith('news', 1); + expect(container.querySelector('ol')).toHaveAttribute('start', '1'); + }); + + it('shows an error message when the feed cannot be loaded', async () => { + fetchFeedMock.mockRejectedValue(new Error('boom')); + + renderFeed('ask', '/ask/1'); + + expect(await screen.findByText('Could not load ask stories.')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).toBeNull(); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + it('hides the previous link on the first page and shows More for a full page', async () => { + fetchFeedMock.mockResolvedValue(makeStories(30)); + + renderFeed(); + + const more = await screen.findByRole('link', { name: 'More ›' }); + expect(more).toHaveAttribute('href', '/news/2'); + expect(more).toHaveClass('more'); + expect(screen.queryByRole('link', { name: '‹ Prev' })).toBeNull(); + }); + + it('shows both pagination links on a full later page', async () => { + fetchFeedMock.mockResolvedValue(makeStories(30, 61)); + + renderFeed('news', '/news/3'); + + const prev = await screen.findByRole('link', { name: '‹ Prev' }); + expect(prev).toHaveAttribute('href', '/news/2'); + expect(prev).toHaveClass('prev'); + expect(screen.getByRole('link', { name: 'More ›' })).toHaveAttribute('href', '/news/4'); + }); + + it('hides the More link when the page is not full', async () => { + fetchFeedMock.mockResolvedValue(makeStories(29, 31)); + + renderFeed('news', '/news/2'); + + await screen.findByRole('link', { name: '‹ Prev' }); + expect(screen.queryByRole('link', { name: 'More ›' })).toBeNull(); + }); + + it('renders the jobs blurb and omits the list margin for the jobs feed', async () => { + fetchFeedMock.mockResolvedValue(makeStories(2)); + + const { container } = renderFeed('jobs', '/jobs/1'); + + await screen.findByText(/These are jobs at startups/); + expect(screen.getByRole('link', { name: 'Triplebyte' })).toHaveAttribute( + 'href', + 'https://triplebyte.com/?ref=yc_jobs' + ); + expect(container.querySelector('.job-header')).toBeInTheDocument(); + expect(container.querySelector('ol')).not.toHaveClass('list-margin'); + }); + + it('does not render the jobs blurb for other feeds', async () => { + fetchFeedMock.mockResolvedValue(makeStories(2)); + + const { container } = renderFeed('show', '/show/1'); + + await screen.findByText('Story 1'); + expect(container.querySelector('.job-header')).toBeNull(); + }); + + it('refetches the feed when navigating to the next page', async () => { + fetchFeedMock.mockResolvedValueOnce(makeStories(30)).mockResolvedValueOnce(makeStories(30, 31)); + + renderFeed(); + + await screen.findByText('Story 1'); + await userEvent.click(screen.getByRole('link', { name: 'More ›' })); + + expect(await screen.findByText('Story 31')).toBeInTheDocument(); + await waitFor(() => expect(fetchFeedMock).toHaveBeenCalledTimes(2)); + expect(fetchFeedMock).toHaveBeenLastCalledWith('news', 2); + expect(screen.queryByText('Story 1')).toBeNull(); + }); +}); diff --git a/web/src/pages/FeedPage.tsx b/web/src/pages/FeedPage.tsx index 62025f0a..3110af75 100644 --- a/web/src/pages/FeedPage.tsx +++ b/web/src/pages/FeedPage.tsx @@ -1,12 +1,89 @@ +import { useEffect, useState } from 'react'; +import { NavLink, useParams } from 'react-router-dom'; + +import { fetchFeed } from '../api/hackerNews'; +import { ErrorMessage } from '../components/ErrorMessage'; +import { Loader } from '../components/Loader'; +import { Item } from '../feeds/Item'; +import { Story } from '../models/story'; +import '../feeds/feed.scss'; + export interface FeedPageProps { feedType: string; } -/** - * Placeholder for the ported `FeedComponent`, implemented in Phase 2b. - */ -export function FeedPage(_props: FeedPageProps) { - return null; +export function FeedPage({ feedType }: FeedPageProps) { + const { page } = useParams(); + const pageNum = page ? Number(page) : 1; + const [items, setItems] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + let cancelled = false; + + setItems(null); + setErrorMessage(''); + + fetchFeed(feedType, pageNum).then( + (feedItems) => { + if (cancelled) { + return; + } + setItems(feedItems); + window.scrollTo(0, 0); + }, + () => { + if (!cancelled) { + setErrorMessage(`Could not load ${feedType} stories.`); + } + } + ); + + return () => { + cancelled = true; + }; + }, [feedType, pageNum]); + + const listStart = (pageNum - 1) * 30 + 1; + + return ( +
+ {!items && errorMessage === '' && } + {!items && errorMessage !== '' && } + + {items && ( +
+ {feedType === 'jobs' && ( +

+ These are jobs at startups that were funded by Y Combinator. You can also get a job at a YC + startup through Triplebyte. +

+ )} + {feedType !== 'new' && ( +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+ )} +
+ {listStart !== 1 && ( + + ‹ Prev + + )} + {items.length === 30 && ( + + More › + + )} +
+
+ )} +
+ ); } export default FeedPage; From 9e3e2da4d2acc065a5e4d1be16ca51b78d506140 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:46:16 +0000 Subject: [PATCH 3/5] feature: port the app shell, router, header, footer and settings modal to React Co-Authored-By: Vibha Seshadri --- web/src/App.test.tsx | 69 +++++++++++---- web/src/App.tsx | 29 +++++-- web/src/core/Footer.test.tsx | 17 ++++ web/src/core/Footer.tsx | 16 ++++ web/src/core/Header.test.tsx | 80 ++++++++++++++++++ web/src/core/Header.tsx | 51 +++++++++++ web/src/core/Settings.test.tsx | 141 +++++++++++++++++++++++++++++++ web/src/core/Settings.tsx | 100 ++++++++++++++++++++++ web/src/core/footer.scss | 23 +++++ web/src/core/header.scss | 149 +++++++++++++++++++++++++++++++++ web/src/core/settings.scss | 75 +++++++++++++++++ web/src/main.tsx | 7 +- web/src/routes.test.tsx | 94 +++++++++++++++++++++ web/src/routes.tsx | 31 +++++++ 14 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 web/src/core/Footer.test.tsx create mode 100644 web/src/core/Footer.tsx create mode 100644 web/src/core/Header.test.tsx create mode 100644 web/src/core/Header.tsx create mode 100644 web/src/core/Settings.test.tsx create mode 100644 web/src/core/Settings.tsx create mode 100644 web/src/core/footer.scss create mode 100644 web/src/core/header.scss create mode 100644 web/src/core/settings.scss create mode 100644 web/src/routes.test.tsx create mode 100644 web/src/routes.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index de1a37e5..9323ceac 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,40 +1,81 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import { SettingsProvider } from './context/SettingsContext'; import { stubMatchMedia } from './testUtils/matchMedia'; +function renderApp(initialEntry = '/news/1') { + vi.stubGlobal('scrollTo', vi.fn()); + + return render( + + + + }> + news feed

} /> + show feed

} /> +
+
+
+
+ ); +} + afterEach(() => { vi.unstubAllGlobals(); }); describe('App', () => { - it('wraps its children in the themed shell', () => { + it('renders the themed shell with the header, the routed page and the footer', () => { stubMatchMedia(false); - const { container } = render( - - -

content

-
-
- ); + const { container } = renderApp(); expect(container.querySelector('.default .body-cover')).not.toBeNull(); - expect(screen.getByText('content').parentElement).toHaveClass('wrapper'); + expect(screen.getByText('news feed').parentElement).toHaveClass('wrapper'); + expect(container.querySelector('.wrapper #header')).not.toBeNull(); + expect(container.querySelector('.wrapper #footer')).not.toBeNull(); }); it('applies the theme coming from the settings', () => { localStorage.setItem('theme', 'amoledblack'); stubMatchMedia(false); - const { container } = render( - - - - ); + const { container } = renderApp(); expect(container.querySelector('.amoledblack')).not.toBeNull(); }); + + it('sends a Google Analytics pageview on the initial render and on every navigation', async () => { + stubMatchMedia(false); + const ga = vi.fn(); + vi.stubGlobal('ga', ga); + + renderApp(); + + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/news/1'], + ['send', 'pageview'], + ]); + + ga.mockClear(); + await userEvent.click(screen.getByRole('link', { name: 'show' })); + + expect(screen.getByText('show feed')).toBeInTheDocument(); + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/show/1'], + ['send', 'pageview'], + ]); + }); + + it('does not throw when Google Analytics is not loaded', () => { + stubMatchMedia(false); + vi.stubGlobal('ga', undefined); + + expect(() => renderApp()).not.toThrow(); + expect(screen.getByText('news feed')).toBeInTheDocument(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index ed63d362..c47d2f5c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,19 +1,38 @@ -import { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; import { useSettings } from './context/SettingsContext'; +import { Footer } from './core/Footer'; +import { Header } from './core/Header'; import './App.scss'; -export interface AppProps { - children?: ReactNode; +declare global { + interface Window { + ga?: (...args: unknown[]) => void; + } } -export function App({ children }: AppProps) { +export function App() { const { settings } = useSettings(); + const { pathname } = useLocation(); + + useEffect(() => { + if (typeof window.ga !== 'function') { + return; + } + + window.ga('set', 'page', pathname); + window.ga('send', 'pageview'); + }, [pathname]); return (
-
{children}
+
+
+ +
+
); } diff --git a/web/src/core/Footer.test.tsx b/web/src/core/Footer.test.tsx new file mode 100644 index 00000000..91e7c892 --- /dev/null +++ b/web/src/core/Footer.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Footer } from './Footer'; + +describe('Footer', () => { + it('links to the project on GitHub', () => { + const { container } = render(