From 48b31fb2b588489b9d28d86cec5c5ede12394422 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 27 Jul 2026 19:56:20 +0200 Subject: [PATCH] feat: log post views when opening article post page Articles previously only counted a view (for reading streaks/reading days) when the user clicked "Read article"; every other post type already logged a view on page/modal open. Track it unconditionally in PostContent and PostFocusCard so opening an article's post page or modal counts the same way, matching a companion daily-api change that dedups against the existing read-article click. Co-Authored-By: Claude Fable 5 --- packages/shared/src/components/Feed.spec.tsx | 49 +++++++++++++++++++ .../src/components/post/PostContent.tsx | 2 +- .../components/post/focus/PostFocusCard.tsx | 12 +---- packages/webapp/__tests__/PostPage.tsx | 44 +++++++++++++++++ 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 38a9653a69..6157a6fd71 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -1310,6 +1310,14 @@ describe('Feed logged in', () => { }, })); + // Opening the post modal now logs a view for every post type (previously + // only some types tracked here); the `pmid` query param above opens the + // modal on mount, so this must be registered before render. + nock('http://localhost:3000') + .post('/graphql', (body: { query?: string }) => + Boolean(body.query?.includes('mutation ViewPost(')), + ) + .reply(200, { data: { viewPost: { _: true } } }); renderComponent(); await waitForNock(); const [first] = await screen.findAllByLabelText('Comments'); @@ -1414,6 +1422,23 @@ describe('Feed logged in', () => { }); const [firstPost, secondPost] = defaultFeedPage.edges; + // Opening the post modal now logs a view for every post type; the + // `pmid` query param above opens the modal on mount, and this test then + // navigates through three different post/id combinations, so mock + // persistently (registered before render) rather than one-off per post. + // Counts calls so the test can wait for the final navigation's view to + // actually fire before finishing — otherwise the request can resolve + // after this test ends and spuriously fail an unrelated later test. + let viewPostCallCount = 0; + nock('http://localhost:3000') + .persist() + .post('/graphql', (body: { query?: string }) => + Boolean(body.query?.includes('mutation ViewPost(')), + ) + .reply(200, () => { + viewPostCallCount += 1; + return { data: { viewPost: { _: true } } }; + }); renderComponent(); await waitForNock(); @@ -1441,9 +1466,24 @@ describe('Feed logged in', () => { fireEvent.click(previous); const firstTitle = await screen.findByTestId('post-modal-title'); expect(firstTitle).toHaveTextContent(getPostTitle(firstPost.node, 'first')); + + await waitFor(() => expect(viewPostCallCount).toBe(3)); }); it('should report irrelevant tags', async () => { + // The two preceding tests set a `pmid` router query to drive their own + // post-modal navigation, and `jest.clearAllMocks()` in `beforeEach` does + // not reset a `mockImplementation`. Reset it here so this test doesn't + // inherit that `pmid` and inadvertently auto-open the post modal (which, + // now that opening a post logs a view for every type, would otherwise + // fire an unmocked `viewPost` mutation). + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + query: {}, + } as unknown as NextRouter), + ); let mutationCalled = false; renderComponent([ createFeedMock({ @@ -1501,6 +1541,15 @@ describe('Feed logged in', () => { }); it('should keep selected irrelevant tags when reason changes', async () => { + // See comment in the preceding test: reset the router mock so this test + // doesn't inherit a leftover `pmid` query and auto-open the post modal. + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + query: {}, + } as unknown as NextRouter), + ); renderComponent([ createFeedMock({ pageInfo: defaultFeedPage.pageInfo, diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 702972165b..9baf47f812 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -134,7 +134,7 @@ export function PostContentRaw({ hideSubscribeAction, }; - useTrackPostView({ post, shouldTrack: isVideoType }); + useTrackPostView({ post }); const postMainColumn = ( export type FocusCardLeftVariant = 'lean' | 'rich'; -const viewTrackedPostTypes = [ - PostType.Share, - PostType.Collection, - PostType.Freeform, - PostType.Welcome, -]; - interface PostFocusCardProps { post: Post; origin: PostOrigin; @@ -289,10 +282,7 @@ export const PostFocusCard = ({ const [isVideoExpanded, setIsVideoExpanded] = useState(false); const readHref = getReadArticleHref(post); - useTrackPostView({ - post, - shouldTrack: isVideoPost(post) || viewTrackedPostTypes.includes(post.type), - }); + useTrackPostView({ post }); useEffect(() => { if (!isVideoType || isVideoExpanded) { diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index aea4c51b5a..18bc066fa8 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -278,6 +278,17 @@ function renderPost( }, result: () => ({ data: { _: true } }), }, + // Opening any post (including articles) now logs a view on mount; tests + // that don't assert on this explicitly still need it mocked so the + // mutation doesn't fire an unmatched request against nock. Registered + // last so a test-supplied `mocks` entry for the same request wins. + { + request: { + query: VIEW_POST_MUTATION, + variables: { id: defaultProps.id }, + }, + result: () => ({ data: { viewPost: { _: true } } }), + }, ]; defaultMocks.forEach(mockGraphQL); @@ -1035,6 +1046,39 @@ describe('article', () => { }), ); }); + + // Unified view logging: opening an article now logs a view on mount too, + // matching every other post type, rather than only on "Read article" click. + it('should log post view on mount', async () => { + let viewPostMutationCalled = false; + renderPost({}, [ + createPostMock(), + createCommentsMock(), + { + request: { + query: VIEW_POST_MUTATION, + variables: { + id: '0e4005b2d3cf191f8c44c2718a457a1e', + }, + }, + result: () => { + viewPostMutationCalled = true; + + return { + data: { + viewPost: { + _: true, + }, + }, + }; + }, + }, + ]); + + await waitFor(() => { + expect(viewPostMutationCalled).toBe(true); + }); + }); }); describe('post redesign', () => {