Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ const mocks = vi.hoisted(() => ({
taggedFiltersAskedFor: [] as any[],
taggedHasNextPage: false,
fetchNextTaggedPage: vi.fn(),
boundedPagingOverride: null as {
autoPages: boolean;
stoppedShort: boolean;
keepLooking: () => void;
} | null,
entityQueryHasNextPage: false,
/** The hub's claims query (the All tab) is still in flight. */
entityQueryLoading: false,
Expand Down Expand Up @@ -482,6 +487,16 @@ vi.mock('~/core/debates/tagged-claims', async importOriginal => ({
},
}));

vi.mock('~/core/debates/matchmaking/use-bounded-paging', async importOriginal => {
const original = await importOriginal<typeof import('~/core/debates/matchmaking/use-bounded-paging')>();

return {
...original,
useBoundedPaging: (options: Parameters<typeof original.useBoundedPaging>[0]) =>
mocks.boundedPagingOverride ?? original.useBoundedPaging(options),
};
});

const HYDRATION_ERROR = new Error('hydration exploded');

// The opponent's claims are hydrated from the graph by id, through the picker's narrow projection.
Expand Down Expand Up @@ -877,6 +892,7 @@ beforeEach(() => {
mocks.taggedFiltersAskedFor = [];
mocks.taggedHasNextPage = false;
mocks.fetchNextTaggedPage = vi.fn();
mocks.boundedPagingOverride = null;
mocks.entityHydrationErrorFor = null;
mocks.savedClaimsLoading = false;
mocks.savedClaimsError = null;
Expand Down Expand Up @@ -1423,6 +1439,27 @@ describe('DebateRematchPageClient', () => {
expect(toggle.previousElementSibling?.className).toContain('flex-1');
});

it('attributes shared filters and switches to rematch rather than the debate hub', async () => {
render(<DebateRematchPageClient sessionId="rematch-1" />);
await showExplore();

expect(screen.getByRole('button', { name: 'All claims' })).toHaveAttribute(
'data-geo-analytics-label',
'Debate rematch Claims source filter'
);
expect(screen.getByRole('button', { name: /Any space/ })).toHaveAttribute(
'data-geo-analytics-label',
'Debate rematch Space filter'
);
expect(screen.getByRole('button', { name: /Any topic/ })).toHaveAttribute(
'data-geo-analytics-label',
'Debate rematch Topic filter'
);
const toggle = screen.getByRole('switch', { name: 'Hide my positions' });
expect(toggle).toHaveAttribute('data-geo-analytics-label', 'Debate rematch Hide my positions');
expect(toggle).toHaveAttribute('data-geo-analytics-intent', 'filter_debate_rematch');
});

// A fixed order, so a source that appears doesn't reshuffle the ones already in the menu.
it('offers the sources in a fixed order, Recommended first', async () => {
curatedPage();
Expand Down Expand Up @@ -1969,6 +2006,16 @@ describe('DebateRematchPageClient', () => {
expect(screen.queryByRole('button', { name: 'Keep looking' })).toBeNull();
});

it('keeps the same rematch attribution when Keep looking appears below existing rows', async () => {
mocks.boundedPagingOverride = { autoPages: false, stoppedShort: true, keepLooking: vi.fn() };
render(<DebateRematchPageClient sessionId="rematch-1" />);
await showAllClaims();

const keepLooking = screen.getByRole('button', { name: 'Keep looking' });
expect(keepLooking).toHaveAttribute('data-geo-analytics-label', 'Debate rematch Keep looking');
expect(keepLooking).toHaveAttribute('data-geo-analytics-intent', 'debate_rematch_action');
});

it('leaves the sentinel out once there is no page left to fetch', async () => {
render(<DebateRematchPageClient sessionId="rematch-1" />);
await showAllClaims();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2129,6 +2129,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {

<div className="flex flex-col gap-3">
<SpaceTopicFilters
analyticsSurface="rematch"
spaceIds={spaceIds}
onSpaceToggle={onSpaceToggle}
onSpacesClear={onSpacesClear}
Expand Down Expand Up @@ -2164,6 +2165,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
trailing={
tab === 'opponent' ? (
<MatchesOnlySwitch
analyticsSurface="rematch"
// The effective value, not the stored one — see `useNarrowedDefault`.
checked={matchesNarrowed}
onChange={next => {
Expand All @@ -2176,13 +2178,18 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
// change a single row under it. Never on "My positions" either, which is the list
// it would empty.
tab === 'explore' && source !== 'mine' ? (
<HideMyPositionsSwitch checked={hideMyPositions} onChange={setHideMyPositions} />
<HideMyPositionsSwitch
analyticsSurface="rematch"
checked={hideMyPositions}
onChange={setHideMyPositions}
/>
) : null
}
leading={
tab === 'explore' ? (
<HubFilterMenu
label={CLAIMS_SOURCE_LABELS[source]}
analytics={{ name: 'Claims source', surface: 'rematch' }}
options={sourceOptions}
value={source}
onChange={setChosenSource}
Expand Down Expand Up @@ -2219,6 +2226,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
)}

<HubQueryState
analyticsSurface="rematch"
// Only what the visible tab actually draws from, and only while it has nothing to show.
// Holding every tab on the slowest query meant the session's own claims — which arrive in
// one round trip — sat behind a graph-wide scan they don't come from.
Expand Down Expand Up @@ -2332,7 +2340,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
// `HubQueryState` draws its action instead of the rows. Stopping short with rows on screen
// is the ordinary case, and without this the list quietly stopped paging.
<div className="flex justify-center pt-1">
<HubPillButton onClick={keepLooking}>Keep looking</HubPillButton>
<HubPillButton analyticsSurface="rematch" onClick={keepLooking}>
Keep looking
</HubPillButton>
</div>
) : null}
</main>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const mocks = vi.hoisted(() => ({
microphoneTrack: undefined as { track: Record<string, unknown> } | undefined,
useKrispNoiseFilter: vi.fn(() => ({ setNoiseFilterEnabled: vi.fn(() => Promise.resolve()) })),
capture: vi.fn(),
personProfileOpened: vi.fn(),
openSidePanel: vi.fn(),
spaceLookups: [] as Array<string | undefined>,
/**
Expand Down Expand Up @@ -212,6 +213,7 @@ vi.mock('~/design-system/avatar', () => ({

vi.mock('~/core/analytics', () => ({
capture: (...args: unknown[]) => mocks.capture(...args),
personProfileOpened: (...args: unknown[]) => mocks.personProfileOpened(...args),
}));

vi.mock('~/core/hooks/use-space', () => ({
Expand Down Expand Up @@ -398,6 +400,7 @@ beforeEach(() => {
mocks.microphoneTrack = undefined;
mocks.useKrispNoiseFilter.mockClear();
mocks.capture.mockReset();
mocks.personProfileOpened.mockReset();
mocks.openSidePanel.mockReset();
mocks.spaceLookups = [];
mocks.opponentSpace = { topicId: null, entity: { id: 'them-home' } };
Expand Down
7 changes: 6 additions & 1 deletion apps/web/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ export const spaceSidebarHasContentAtom = atom<boolean | null>(null);
* page they should open the comments beside what you're reading rather than
* navigate away from it.
*/
export const entityCommentsPanelAtom = atom<{ entityId: string; spaceId: string } | null>(null);
export const entityCommentsPanelAtom = atom<{
entityId: string;
spaceId: string;
/** Logical graph type retained so comments created from the global panel are attributed correctly. */
targetEntityType?: string;
} | null>(null);

export type DebatesHubTab = 'requests' | 'lobby' | 'explore' | 'positions' | 'people';

Expand Down
96 changes: 96 additions & 0 deletions apps/web/core/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,14 @@ describe('analytics', () => {
const {
browseModeToggled: browse,
commentCreated,
commentEdited,
editModeToggled: edit,
personProfileOpened,
personalSpaceViewed,
profileUpdated,
publishedEdit,
reviewChangesOpened,
signupCompleted,
upvoted: up,
voteCast: vote,
} = await import('./analytics');
Expand All @@ -236,9 +240,13 @@ describe('analytics', () => {
edit({ space_id: 'space-1' });
browse({ space_id: 'space-1' });
personalSpaceViewed('personal-space-1');
personProfileOpened('profile-space-1', 'person-1', { interaction_surface: 'claim_vote_list' });
reviewChangesOpened({ space_id: 'space-1' });
publishedEdit({ space_id: 'space-1' });
profileUpdated('person-1', 'profile-space-1');
commentCreated('comment-1', 'claim-1', { space_id: 'space-1' });
commentEdited('comment-1', 'claim-1', { space_id: 'space-1' });
signupCompleted('newsletter', { signup_surface: 'explore_email_capture' });

expect(upvoted).toHaveBeenCalledWith({ entity_id: 'entity-1' });
expect(voteCast).toHaveBeenCalledWith('none', { entity_id: 'entity-1' });
Expand All @@ -260,6 +268,21 @@ describe('analytics', () => {
source: 'publishing',
space_id: 'space-1',
});
expect(capture).toHaveBeenCalledWith('graph_relationship_followed', {
app: 'genesis',
source: 'person_profile',
entity_id: 'person-1',
graph_entity_type: 'person',
profile_space_id: 'profile-space-1',
interaction_surface: 'claim_vote_list',
});
expect(capture).toHaveBeenCalledWith('published_edit', {
app: 'genesis',
source: 'profile_editor',
content_id: 'person-1',
content_type: 'profile',
space_id: 'profile-space-1',
});
expect(capture).toHaveBeenCalledWith('comment_created', {
app: 'genesis',
source: 'commenting',
Expand All @@ -268,5 +291,78 @@ describe('analytics', () => {
target_id: 'claim-1',
space_id: 'space-1',
});
expect(capture).toHaveBeenCalledWith('content_edited', {
app: 'genesis',
source: 'commenting',
content_id: 'comment-1',
content_type: 'comment',
target_type: 'entity',
target_id: 'claim-1',
space_id: 'space-1',
});
expect(capture).toHaveBeenCalledWith('signup_completed', {
app: 'genesis',
source: 'signup_form',
form_type: 'newsletter',
signup_surface: 'explore_email_capture',
});
});

it('keeps product actions best-effort when the analytics runtime throws', async () => {
window.lytics = {
capture: vi.fn(() => {
throw new Error('collector unavailable');
}),
};

const { personProfileOpened } = await import('./analytics');

expect(() => personProfileOpened('profile-space-1', 'person-1')).not.toThrow();
});

it('attributes a profile entity fallback as its personal space', async () => {
const capture = vi.fn();
window.lytics = { capture };

const { personProfileOpened } = await import('./analytics');

personProfileOpened('profile-space-1', 'profile-space-1', { interaction_surface: 'claim_vote_list' });

expect(capture).toHaveBeenCalledWith('graph_relationship_followed', {
app: 'genesis',
source: 'person_profile',
entity_id: 'profile-space-1',
graph_entity_type: 'personal_space',
profile_space_id: 'profile-space-1',
interaction_surface: 'claim_vote_list',
});
});

it('recognizes dashed and differently cased forms of the same personal-space id', async () => {
const capture = vi.fn();
window.lytics = { capture };

const { personProfileOpened } = await import('./analytics');

personProfileOpened('4C81561D-1F95-4131-9CDD-DD20AB831BA2', '4c81561d1f9541319cdddd20ab831ba2');

expect(capture).toHaveBeenCalledWith('graph_relationship_followed', {
app: 'genesis',
source: 'person_profile',
entity_id: '4C81561D-1F95-4131-9CDD-DD20AB831BA2',
graph_entity_type: 'personal_space',
profile_space_id: '4C81561D-1F95-4131-9CDD-DD20AB831BA2',
});
});

it('does not attribute a profile open to a pending personal-space sentinel', async () => {
const capture = vi.fn();
window.lytics = { capture };

const { personProfileOpened } = await import('./analytics');

personProfileOpened('pending:0x123', null, { interaction_surface: 'comment_author' });

expect(capture).not.toHaveBeenCalled();
});
});
Loading
Loading