Skip to content

Commit e664990

Browse files
davidercruzclaude
andcommitted
feat(persona-quiz): persist tags incrementally + use shared Feed inter-question
Previously the inter-question feed preview used a bespoke component (`PersonaQuizFeedPreview`) that fired its own `feedPreview` query with ad-hoc tag filters. Meanwhile the reveal screen used the shared `Feed` component reading from persisted `feedSettings`. Two parallel implementations of the same idea, with the reveal one being the better-tested code path. Unify on the persistence-based approach: - After every answer, follow newly-earned tags via `followTags` (the same hook `TagSelection` uses). `followedRef` tracks the session's delta so we never re-fire follow calls for tags we've already streamed. - Debounced `refetchPreview` invalidates `[RequestKey.FeedPreview]` cache entries after each follow burst, prompting the shared `Feed` to re-fetch. - Replace `PersonaQuizFeedPreview` JSX with the standard `Feed` + `FeedLayoutProvider` block (same as `EditTag` and `PersonaQuizReveal`). - `PersonaQuizFeedPreview.tsx` deleted entirely. Side effect: the user's actual `feedSettings` shapes live as they answer the quiz. By the time they reach the reveal, all quiz tags are already followed and `TagSelection` shows them as selected without any seeding work. `finishQuiz` now only fires `followTags` for tags that haven't been streamed yet (typically just the fallback backfill). Tests: - Updated the "walks Q→A→reveal" assertion to collect the union of every `followTags` call rather than expecting a single batched one. - Added `SettingsContext` and `Feed` mocks so the orchestration tests don't try to mount the real Feed pipeline. - All 4 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f05253 commit e664990

3 files changed

Lines changed: 77 additions & 236 deletions

File tree

packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz/FunnelPersonaQuiz.spec.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ jest.mock('../../../../hooks/useConditionalFeature', () => ({
3737
useConditionalFeature: () => ({ value: false }),
3838
}));
3939

40+
jest.mock('../../../../contexts/SettingsContext', () => ({
41+
useSettingsContext: () => ({
42+
sidebarExpanded: false,
43+
autoDismissNotifications: false,
44+
}),
45+
ThemeMode: { Light: 'light', Dark: 'dark' },
46+
}));
47+
48+
// Heavy Feed component is exercised by other test suites; here we only need it
49+
// to mount without exploding so the orchestration's inter-question render
50+
// path is covered.
51+
jest.mock('../../../../components/Feed', () => ({
52+
__esModule: true,
53+
default: () => null,
54+
}));
55+
4056
const mockFollowTags = jest.fn().mockResolvedValue(undefined);
4157
jest.mock('../../../../hooks/useMutateFilters', () => ({
4258
__esModule: true,
@@ -220,9 +236,14 @@ describe('FunnelPersonaQuiz', () => {
220236
).toBeInTheDocument();
221237
fireEvent.click(screen.getByText('Looks good'));
222238
await waitFor(() => {
223-
expect(mockFollowTags).toHaveBeenCalledWith({
224-
tags: expect.arrayContaining(['react', 'tailwind', 'typescript']),
225-
});
239+
// Tags are followed incrementally during the quiz — collect the union
240+
// of every `followTags` call so we can assert the full quiz tag set.
241+
const allFollowedTags = mockFollowTags.mock.calls.flatMap(
242+
([{ tags }]) => tags,
243+
);
244+
expect(allFollowedTags).toEqual(
245+
expect.arrayContaining(['react', 'tailwind', 'typescript']),
246+
);
226247
});
227248
await waitFor(() => {
228249
expect(onTransition).toHaveBeenCalledWith(

packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz/PersonaQuizFeedPreview.tsx

Lines changed: 0 additions & 226 deletions
This file was deleted.

packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz/index.tsx

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, {
66
useRef,
77
useState,
88
} from 'react';
9-
import { useMutation } from '@tanstack/react-query';
9+
import { useMutation, useQueryClient } from '@tanstack/react-query';
1010
import type {
1111
FunnelStepPersonaQuiz,
1212
FunnelStepPersonaQuizParameters,
@@ -20,11 +20,15 @@ import { useLogContext } from '../../../../contexts/LogContext';
2020
import { LogEvent } from '../../../../lib/log';
2121
import useMutateFilters from '../../../../hooks/useMutateFilters';
2222
import useFeedSettings from '../../../../hooks/useFeedSettings';
23+
import useDebounceFn from '../../../../hooks/useDebounceFn';
2324
import { usePersonaQuizState } from './usePersonaQuizState';
2425
import type { PersonaQuizAnswer } from './usePersonaQuizState';
2526
import { PersonaQuizQuestionView } from './PersonaQuizQuestion';
26-
import { PersonaQuizFeedPreview } from './PersonaQuizFeedPreview';
2727
import { PersonaQuizReveal } from './PersonaQuizReveal';
28+
import Feed from '../../../../components/Feed';
29+
import { FeedLayoutProvider } from '../../../../contexts/FeedContext';
30+
import { OtherFeedPage, RequestKey } from '../../../../lib/query';
31+
import { PREVIEW_FEED_QUERY } from '../../../../graphql/feed';
2832

2933
const dedupePreserveOrder = (tags: string[]): string[] =>
3034
Array.from(new Set(tags));
@@ -39,6 +43,7 @@ function FunnelPersonaQuizComponent({
3943
const user = auth?.user;
4044
const { followTags } = useMutateFilters(user);
4145
const { feedSettings } = useFeedSettings();
46+
const queryClient = useQueryClient();
4247

4348
const params = parameters as FunnelStepPersonaQuizParameters;
4449
const {
@@ -87,6 +92,31 @@ function FunnelPersonaQuizComponent({
8792
[tagScores, selection.tagConfidenceFloor],
8893
);
8994

95+
// Incrementally persist newly-earned tags after each answer so the shared
96+
// Feed preview reflects the user's evolving interests live. We track the
97+
// session's "already followed" set so we don't re-fire follow calls for
98+
// tags we've already sent.
99+
const followedRef = useRef<Set<string>>(new Set());
100+
const [refetchPreview] = useDebounceFn(() => {
101+
queryClient.invalidateQueries({
102+
queryKey: [RequestKey.FeedPreview],
103+
predicate: (q) => !q.queryKey.includes(RequestKey.FeedPreviewCustom),
104+
});
105+
}, 800);
106+
107+
useEffect(() => {
108+
if (phase !== 'question' || !user?.id) {
109+
return;
110+
}
111+
const fresh = accumulatedTags.filter((t) => !followedRef.current.has(t));
112+
if (fresh.length === 0) {
113+
return;
114+
}
115+
fresh.forEach((t) => followedRef.current.add(t));
116+
Promise.resolve(followTags({ tags: fresh })).catch(() => undefined);
117+
refetchPreview();
118+
}, [accumulatedTags, phase, user?.id, followTags, refetchPreview]);
119+
90120
// Resolve the archetype from the terminal question's `archetypeId`, build
91121
// the final tag list, persist it (so `TagSelection` on the reveal screen
92122
// sees the quiz tags pre-selected via `feedSettings`), then transition to
@@ -106,9 +136,14 @@ function FunnelPersonaQuizComponent({
106136
...(selection.fallbackTags ?? []),
107137
]).slice(0, selection.targetTotalTags);
108138

109-
// Fire-and-forget — TagSelection re-renders when feedSettings updates.
110-
if (merged.length > 0) {
111-
Promise.resolve(followTags({ tags: merged })).catch(() => undefined);
139+
// Most tags are already followed (we stream them incrementally as the
140+
// user answers). Only fire `followTags` for the residue — typically the
141+
// fallback tags backfilled to reach `targetTotalTags`.
142+
const fresh = merged.filter((t) => !followedRef.current.has(t));
143+
if (fresh.length > 0) {
144+
fresh.forEach((t) => followedRef.current.add(t));
145+
Promise.resolve(followTags({ tags: fresh })).catch(() => undefined);
146+
refetchPreview();
112147
}
113148

114149
enrichmentComplete(merged);
@@ -121,6 +156,7 @@ function FunnelPersonaQuizComponent({
121156
selection.fallbackTags,
122157
enrichmentComplete,
123158
followTags,
159+
refetchPreview,
124160
],
125161
);
126162

@@ -259,8 +295,18 @@ function FunnelPersonaQuizComponent({
259295
totalSteps={selection.maxQuestions}
260296
onSelect={handleAnswer}
261297
/>
262-
{answers.length >= 1 && (
263-
<PersonaQuizFeedPreview includeTags={accumulatedTags} />
298+
{answers.length >= 1 && user?.id && (
299+
<FeedLayoutProvider>
300+
<Feed
301+
className="relative mx-auto px-6 pt-10 tablet:left-1/2 tablet:w-screen tablet:-translate-x-1/2"
302+
feedName={OtherFeedPage.Preview}
303+
feedQueryKey={[RequestKey.FeedPreview, user.id]}
304+
query={PREVIEW_FEED_QUERY}
305+
showSearch={false}
306+
options={{ refetchOnMount: false }}
307+
allowPin
308+
/>
309+
</FeedLayoutProvider>
264310
)}
265311
</div>
266312
);

0 commit comments

Comments
 (0)