From 176d8c586089b5afaf03d3ddf9e052fb26559f25 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Mon, 21 Sep 2026 15:46:39 -0700 Subject: [PATCH 01/10] feat: complete frontend analytics attribution --- apps/web/core/analytics.test.ts | 38 ++++++++++++ apps/web/core/analytics.ts | 50 +++++++++++++++ .../core/claims/browse/claim-page-view.tsx | 2 +- .../claims/browse/claim-position-comment.tsx | 5 +- .../core/claims/browse/claim-provenance.tsx | 11 +++- .../claims/browse/claim-side-responders.tsx | 7 ++- .../browse/debate-claim-ticker.test.tsx | 11 ++++ .../debates/browse/debate-claims-panel.tsx | 9 ++- apps/web/core/debates/browse/debate-feed.tsx | 10 ++- .../debates/browse/debate-interaction-bar.tsx | 4 ++ .../browse/use-open-debater-profile.ts | 5 ++ .../matchmaking/debates-hub-button.tsx | 2 + .../debates/matchmaking/debates-hub-panel.tsx | 6 ++ .../debates/matchmaking/hub-pill-button.tsx | 1 + .../debates/matchmaking/people-tab.test.tsx | 25 +++++--- .../core/debates/matchmaking/people-tab.tsx | 9 ++- apps/web/core/hooks/use-edit-profile.test.tsx | 5 ++ apps/web/core/hooks/use-edit-profile.ts | 62 +++++++++++-------- .../core/hooks/use-publish-comment.test.ts | 42 ++++++++++++- apps/web/core/hooks/use-publish-comment.ts | 39 ++++++++++-- .../core/topics/browse/topic-page-view.tsx | 4 +- .../proposal-comments-panel.tsx | 2 +- .../partials/comments/comments-section.tsx | 32 ++++++++-- .../comments/entity-comments-panel.tsx | 13 +++- .../entity-page/entity-vote-buttons.tsx | 11 +++- .../explore/email-capture-account-step.tsx | 16 ++++- .../explore/email-capture-popup.test.tsx | 32 ++++++++++ .../partials/explore/email-capture-popup.tsx | 17 ++++- .../explore/explore-welcome-banner.tsx | 2 + .../partials/profile/edit-profile-dialog.tsx | 2 + .../partials/space-page/space-member-row.tsx | 14 ++++- 31 files changed, 425 insertions(+), 63 deletions(-) diff --git a/apps/web/core/analytics.test.ts b/apps/web/core/analytics.test.ts index a9c7c0ac5b..21cec19f36 100644 --- a/apps/web/core/analytics.test.ts +++ b/apps/web/core/analytics.test.ts @@ -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'); @@ -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' }); @@ -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', @@ -268,5 +291,20 @@ 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', + }); }); }); diff --git a/apps/web/core/analytics.ts b/apps/web/core/analytics.ts index 2f0019bad1..139b5970a3 100644 --- a/apps/web/core/analytics.ts +++ b/apps/web/core/analytics.ts @@ -270,6 +270,27 @@ export function personalSpaceViewed(personalSpaceId: string, properties: Analyti }); } +export function graphRelationshipFollowed(entityId: string, properties: AnalyticsProperties = {}) { + capture('graph_relationship_followed', { + source: 'graph', + entity_id: entityId, + ...properties, + }); +} + +export function personProfileOpened( + profileSpaceId: string, + personEntityId?: string | null, + properties: AnalyticsProperties = {} +) { + graphRelationshipFollowed(personEntityId || profileSpaceId, { + source: 'person_profile', + graph_entity_type: personEntityId ? 'person' : 'personal_space', + profile_space_id: profileSpaceId, + ...properties, + }); +} + export function reviewChangesOpened(properties: AnalyticsProperties = {}) { capture('review_changes_opened', { source: 'review_changes', @@ -284,6 +305,16 @@ export function publishedEdit(properties: AnalyticsProperties = {}) { }); } +export function profileUpdated(profileEntityId: string, spaceId: string, properties: AnalyticsProperties = {}) { + publishedEdit({ + source: 'profile_editor', + content_id: profileEntityId, + content_type: 'profile', + space_id: spaceId, + ...properties, + }); +} + export function commentCreated(commentId: string, targetEntityId: string, properties: AnalyticsProperties = {}) { capture('comment_created', { source: 'commenting', @@ -294,6 +325,25 @@ export function commentCreated(commentId: string, targetEntityId: string, proper }); } +export function commentEdited(commentId: string, targetEntityId: string, properties: AnalyticsProperties = {}) { + capture('content_edited', { + source: 'commenting', + content_id: commentId, + content_type: 'comment', + target_type: 'entity', + target_id: targetEntityId, + ...properties, + }); +} + +export function signupCompleted(formType: string, properties: AnalyticsProperties = {}) { + capture('signup_completed', { + source: 'signup_form', + form_type: formType, + ...properties, + }); +} + export function signedUp(user: AnalyticsIdentity, properties: AnalyticsProperties = {}) { callOrQueue({ method: 'signedUp', diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 1078a4c853..373e2ff61e 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -467,7 +467,7 @@ function ClaimTabPanel({ viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} > - + ); diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index 31f862de90..8de24180b3 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -66,7 +66,10 @@ export function ClaimPositionCommentControl({ const [actionsBelow, setActionsBelow] = React.useState(false); const composerRef = React.useRef(null); const textareaRef = React.useRef(null); - const { publishComment: submitComment } = usePublishComment(entityId, spaceId); + const { publishComment: submitComment } = usePublishComment(entityId, spaceId, { + targetEntityType: 'claim', + interactionSurface: 'claim_position_explanation', + }); const closeComposer = React.useCallback(() => { setPromptedPosition(null); diff --git a/apps/web/core/claims/browse/claim-provenance.tsx b/apps/web/core/claims/browse/claim-provenance.tsx index 217054ba44..d9adc3e8a1 100644 --- a/apps/web/core/claims/browse/claim-provenance.tsx +++ b/apps/web/core/claims/browse/claim-provenance.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import { personProfileOpened } from '~/core/analytics'; import { AUTHORS_PROPERTY_ID, DEBATE_CLAIMS_PROPERTY_ID, @@ -134,7 +135,15 @@ export function ClaimProvenance({ nothing is worse than plain text. `whitespace-nowrap` keeps a two-word name from breaking across lines. */} {speaker.profile?.profileLink ? ( - + + personProfileOpened(speaker.profile!.spaceId, speaker.profile!.id, { + interaction_surface: 'claim_provenance', + }) + } + className="whitespace-nowrap text-text hover:underline" + > {speaker.profile.name} ) : ( diff --git a/apps/web/core/claims/browse/claim-side-responders.tsx b/apps/web/core/claims/browse/claim-side-responders.tsx index b54954a80e..22c0afeab4 100644 --- a/apps/web/core/claims/browse/claim-side-responders.tsx +++ b/apps/web/core/claims/browse/claim-side-responders.tsx @@ -152,7 +152,12 @@ function ResponderList({ spaceIds, label, totalCount }: { spaceIds: string[]; la ) : ( sortedProfiles.map(profile => ( - + )) )} diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 462e752d88..954f213704 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -69,6 +69,8 @@ vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() // The card's header names its speaker and links to them, which reaches for the side panel and for // the space behind the profile. Neither is what these tests are about. const openSidePanel = vi.fn(); +const analyticsMocks = vi.hoisted(() => ({ personProfileOpened: vi.fn() })); +vi.mock('~/core/analytics', () => ({ personProfileOpened: analyticsMocks.personProfileOpened })); vi.mock('~/core/hooks/use-entity-side-panel', () => ({ useEntitySidePanel: () => ({ openSidePanel, closeSidePanel: vi.fn(), sidePanelTarget: null }), })); @@ -159,6 +161,7 @@ describe('DebateClaimTickerCard', () => { // The same link as the name in the corner of the tile, and the same person. it('opens the speaker from the card, the way the tile corner does', () => { openSidePanel.mockClear(); + analyticsMocks.personProfileOpened.mockClear(); renderCard(); fireEvent.click(screen.getByRole('button', { name: 'Peter Feldip' })); @@ -166,6 +169,14 @@ describe('DebateClaimTickerCard', () => { expect(openSidePanel).toHaveBeenCalledWith(`page-${SPEAKER.profile_space_id}`, SPEAKER.profile_space_id, false, { forceRequestedSpace: true, }); + expect(analyticsMocks.personProfileOpened).toHaveBeenCalledWith( + SPEAKER.profile_space_id, + `page-${SPEAKER.profile_space_id}`, + { + interaction_surface: 'debate_media', + navigation_mode: 'entity_side_panel', + } + ); }); // The video behind is one big play/pause button. diff --git a/apps/web/core/debates/browse/debate-claims-panel.tsx b/apps/web/core/debates/browse/debate-claims-panel.tsx index 046c71a07e..0f437610ce 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.tsx @@ -150,7 +150,14 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose Claims · {claims.totalCount} - diff --git a/apps/web/core/debates/browse/debate-feed.tsx b/apps/web/core/debates/browse/debate-feed.tsx index f19a101000..f33dc71b40 100644 --- a/apps/web/core/debates/browse/debate-feed.tsx +++ b/apps/web/core/debates/browse/debate-feed.tsx @@ -381,7 +381,13 @@ export function DebatesBrowseFeed({ ) : openPanel === 'comments' && activeDebate ? ( // Keyed so scrolling to the next debate resets the panel rather than // carrying a half-typed reply across to a different debate's thread. - + ) : null; // Keep the feed in the same tree position whether or not a side panel is open, so @@ -591,6 +597,8 @@ function DebateTitleHeader({
- +
diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index d79e906ac9..9e07f6b40d 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -10,7 +10,7 @@ import cx from 'classnames'; import { Effect } from 'effect'; import { useStore } from 'jotai'; -import { trackPrivyAuth } from '~/core/analytics'; +import { personProfileOpened, trackPrivyAuth } from '~/core/analytics'; import { useEntityResponse } from '~/core/hooks/use-entity-vote'; import { usePrepareOnboarding } from '~/core/hooks/use-prepare-onboarding'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; @@ -591,7 +591,14 @@ function VoterRow({ profile }: { profile: Profile }) { ); if (profile.profileLink) { - return {content}; + return ( + personProfileOpened(profile.spaceId, profile.id, { interaction_surface: 'claim_vote_list' })} + > + {content} + + ); } return content; diff --git a/apps/web/partials/explore/email-capture-account-step.tsx b/apps/web/partials/explore/email-capture-account-step.tsx index 2a78060fe5..7428e5cff5 100644 --- a/apps/web/partials/explore/email-capture-account-step.tsx +++ b/apps/web/partials/explore/email-capture-account-step.tsx @@ -41,7 +41,13 @@ export function AccountStep({ email, onGiveUp }: { email: string; onGiveUp: () = loginWithCode, state: otpState, } = useLoginWithEmail({ - onComplete: args => trackPrivyAuth(args, { auth_flow: 'manual_login', link_source: 'explore_email_capture' }), + onComplete: args => + trackPrivyAuth(args, { + auth_flow: 'manual_login', + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }), }); // Here for the same reason as the hook above, and it is the one that matters more: this registers // a second `useLogin` beside the navbar's own, and the navbar's is the login button people @@ -146,7 +152,13 @@ export function AccountStep({ email, onGiveUp }: { email: string; onGiveUp: () = }, [busy]); return ( -
+ {/* The card's own subtext style, shared from the popup so the two states are one design rather than two that drifted. */}

diff --git a/apps/web/partials/explore/email-capture-popup.test.tsx b/apps/web/partials/explore/email-capture-popup.test.tsx index 7c2ae98c67..f8e0a3981a 100644 --- a/apps/web/partials/explore/email-capture-popup.test.tsx +++ b/apps/web/partials/explore/email-capture-popup.test.tsx @@ -27,6 +27,13 @@ const mocks = vi.hoisted(() => ({ useGeoLoginWithEmail: vi.fn(), usePrivySignIn: vi.fn(), useLoginWithEmailArgs: undefined as unknown, + signupCompleted: vi.fn(), + trackPrivyAuth: vi.fn(), +})); + +vi.mock('~/core/analytics', () => ({ + signupCompleted: mocks.signupCompleted, + trackPrivyAuth: mocks.trackPrivyAuth, })); vi.mock('@geogenesis/auth', () => ({ @@ -89,6 +96,8 @@ beforeEach(() => { mocks.prepareOnboarding.mockReset(); mocks.useGeoLoginWithEmail.mockReset(); mocks.usePrivySignIn.mockReset(); + mocks.signupCompleted.mockReset(); + mocks.trackPrivyAuth.mockReset(); mocks.useLoginWithEmailArgs = undefined; mocks.otpState = { status: 'initial' }; mocks.fetch.mockReset(); @@ -121,6 +130,8 @@ describe('ExploreEmailCapturePopup', () => { scrollPastTrigger(); expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-label', 'Explore newsletter signup'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-type', 'newsletter'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-intent', 'signup'); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'reader@example.com' } }); await act(async () => { @@ -129,6 +140,17 @@ describe('ExploreEmailCapturePopup', () => { fireEvent.click(screen.getByRole('button', { name: 'Create account' })); expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-label', 'Explore account verification'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-type', 'account'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-intent', 'signup'); + }); + + it('records a completed newsletter signup only after the subscription succeeds', async () => { + await subscribeSuccessfully(); + + expect(mocks.signupCompleted).toHaveBeenCalledWith('newsletter', { + signup_surface: 'explore_email_capture', + newsletter_source: 'explore', + }); }); it('stays away until the reader has scrolled', () => { @@ -856,6 +878,16 @@ describe('ExploreEmailCapturePopup', () => { const args = mocks.useLoginWithEmailArgs as { onComplete?: (a: unknown) => void } | undefined; expect(typeof args?.onComplete).toBe('function'); + + const completion = { user: { id: 'did:privy:new-user' }, isNewUser: true }; + args?.onComplete?.(completion); + + expect(mocks.trackPrivyAuth).toHaveBeenCalledWith(completion, { + auth_flow: 'manual_login', + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }); }); // Both resend controls used to stay live while a verification was in flight, so pressing one diff --git a/apps/web/partials/explore/email-capture-popup.tsx b/apps/web/partials/explore/email-capture-popup.tsx index dad6677e4a..956f31bd9d 100644 --- a/apps/web/partials/explore/email-capture-popup.tsx +++ b/apps/web/partials/explore/email-capture-popup.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import cx from 'classnames'; import { useAtomValue } from 'jotai'; +import { signupCompleted } from '~/core/analytics'; import { useDebatesHub } from '~/core/debates/matchmaking/use-debates-hub'; import { useAnyModalOpen } from '~/core/hooks/use-any-modal-open'; import { useDismissedNotice } from '~/core/hooks/use-dismissed-notice'; @@ -189,6 +190,14 @@ function EmailCapturePopup() { }); const body = (await response.json()) as { result?: NewsletterSubscribeResult }; if (body.result === 'subscribed') { + try { + signupCompleted('newsletter', { + signup_surface: 'explore_email_capture', + newsletter_source: 'explore', + }); + } catch { + /* Analytics must never turn a successful subscription into a failed one. */ + } setStatus('done'); setSubscribedEmail(email.trim()); // Recorded, not closed. Having joined is the strongest reason not to ask again next @@ -373,7 +382,13 @@ function EmailCapturePopup() { // different wording in every browser, unstyleable, and gone the moment they look away. // `inputMode` and `autoComplete` keep the phone keyboard and the autofill that // `type="email"` was there for. - +

Geo network launching soon!

Get updates on features, points, and path to mainnet.

diff --git a/apps/web/partials/explore/explore-welcome-banner.tsx b/apps/web/partials/explore/explore-welcome-banner.tsx index 68cb165b8b..d6e6093db7 100644 --- a/apps/web/partials/explore/explore-welcome-banner.tsx +++ b/apps/web/partials/explore/explore-welcome-banner.tsx @@ -77,6 +77,8 @@ function WelcomeBanner() { opener makes. */} @@ -28,7 +36,7 @@ afterEach(() => { function renderHost() { const store = createStore(); - store.set(entityCommentsPanelAtom, { entityId: 'entity-1', spaceId: 'space-1' }); + store.set(entityCommentsPanelAtom, { entityId: 'entity-1', spaceId: 'space-1', targetEntityType: 'claim' }); const view = render(
Page behind the panel
@@ -39,6 +47,15 @@ function renderHost() { } describe('EntityCommentsPanelHost', () => { + it('retains the target type for comment analytics inside the global panel', () => { + renderHost(); + + expect(screen.getByText('Comments for entity-1').closest('aside')).toHaveAttribute( + 'data-target-entity-type', + 'claim' + ); + }); + it('closes when the reader clicks the page behind it', () => { const { store } = renderHost(); expect(screen.getByText('Comments for entity-1')).toBeInTheDocument(); diff --git a/apps/web/partials/comments/entity-comments-panel-host.tsx b/apps/web/partials/comments/entity-comments-panel-host.tsx index 8e0f002155..f0c8119e1b 100644 --- a/apps/web/partials/comments/entity-comments-panel-host.tsx +++ b/apps/web/partials/comments/entity-comments-panel-host.tsx @@ -68,6 +68,7 @@ export function EntityCommentsPanelHost() { , diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 270f7539df..83f6245bf7 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -319,6 +319,7 @@ export function ClaimExploreFeedCard({ diff --git a/apps/web/partials/explore/debate-explore-feed-card.test.tsx b/apps/web/partials/explore/debate-explore-feed-card.test.tsx index 95d3525725..9fc708dd8a 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.test.tsx @@ -13,7 +13,7 @@ import type { ExploreFeedItem } from '~/core/explore/fetch-explore-feed'; import { NavUtils } from '~/core/utils/utils'; import { DebateExploreFeedCard } from './debate-explore-feed-card'; -import { entitySidePanelAtom } from '~/atoms'; +import { entityCommentsPanelAtom, entitySidePanelAtom } from '~/atoms'; // Reached through the claims panel, which now carries the shared response controls. The module's // top-level `atomWithStorage` runs on import, and under Node's own webstorage — which shadows @@ -247,6 +247,15 @@ function PanelProbe() { return
{target ? `${target.entityId} in ${target.spaceId}` : 'closed'}
; } +function CommentsPanelProbe() { + const target = useAtomValue(entityCommentsPanelAtom); + return ( +
+ {target ? `${target.entityId} in ${target.spaceId} (${target.targetEntityType})` : 'closed'} +
+ ); +} + // The card is rendered inside the app's query provider — its comment count reads the comments cache — // so the harness has to provide one too, or the double is laxer than the real tree. let client: QueryClient; @@ -265,6 +274,7 @@ function CardHarness({ {allowedId === undefined ? card : {card}} + ); @@ -558,10 +568,20 @@ describe('DebateExploreFeedCard', () => { const comments = screen.getByRole('button', { name: /^Comments/ }); expect(comments.textContent).toBe('3'); expect(screen.getByRole('button', { name: /^Claims/ }).textContent).toBe('18'); + expect(comments).toHaveAttribute('data-geo-analytics-label', 'Debate comments'); + expect(screen.getByRole('button', { name: /^Claims/ })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate claims' + ); // Marked as an opener so pressing it while the global comments panel is open switches the // panel to this debate instead of reading as an outside click that dismisses it. expect(comments.hasAttribute('data-entity-comments-opener')).toBe(true); + + fireEvent.click(comments); + expect(screen.getByTestId('comments-panel-target')).toHaveTextContent( + `${item.entityId} in ${item.spaceId} (debate)` + ); }); it('keeps votes and comments while the debate is still loading', () => { diff --git a/apps/web/partials/explore/debate-explore-feed-card.tsx b/apps/web/partials/explore/debate-explore-feed-card.tsx index 123510a2fc..9c3d853154 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.tsx @@ -311,7 +311,7 @@ export function DebateExploreFeedCard({ spaceId: item.spaceId, commentCount, commentsPanelOpen: commentsTarget?.entityId === item.entityId, - onComment: () => openComments(item.entityId, item.spaceId), + onComment: () => openComments(item.entityId, item.spaceId, 'debate'), claimsCount: claims.totalCount, // See the prop's own note: an explore card can be a data block row listing a debate from // another space, and only the lookup finds that space's votes. From a1b97adf54fb2435ad1cafe4bac932cfd94c14b3 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Mon, 21 Sep 2026 17:12:41 -0700 Subject: [PATCH 03/10] fix: keep analytics failures isolated --- apps/web/core/analytics.test.ts | 30 ++++++++++++++++++++++++++++++ apps/web/core/analytics.ts | 17 +++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/core/analytics.test.ts b/apps/web/core/analytics.test.ts index 21cec19f36..d04d940dd7 100644 --- a/apps/web/core/analytics.test.ts +++ b/apps/web/core/analytics.test.ts @@ -307,4 +307,34 @@ describe('analytics', () => { 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', + }); + }); }); diff --git a/apps/web/core/analytics.ts b/apps/web/core/analytics.ts index 139b5970a3..89168a00c8 100644 --- a/apps/web/core/analytics.ts +++ b/apps/web/core/analytics.ts @@ -283,9 +283,11 @@ export function personProfileOpened( personEntityId?: string | null, properties: AnalyticsProperties = {} ) { - graphRelationshipFollowed(personEntityId || profileSpaceId, { + const resolvedPersonEntityId = personEntityId && personEntityId !== profileSpaceId ? personEntityId : null; + + graphRelationshipFollowed(resolvedPersonEntityId ?? profileSpaceId, { source: 'person_profile', - graph_entity_type: personEntityId ? 'person' : 'personal_space', + graph_entity_type: resolvedPersonEntityId ? 'person' : 'personal_space', profile_space_id: profileSpaceId, ...properties, }); @@ -491,6 +493,17 @@ function flushPendingCalls() { } function invokeRuntime(call: PendingCall) { + try { + return invokeRuntimeUnsafe(call); + } catch (error) { + // Analytics is observational. A collector/runtime failure must not break the product action + // that emitted the event, and retrying the same broken call would also stall the pending queue. + console.error('Analytics runtime call failed:', error); + return true; + } +} + +function invokeRuntimeUnsafe(call: PendingCall) { const analytics = analyticsRuntime(); if (!analytics) { From 04b58a4fa809d5924aad391471156c5b7623da82 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Mon, 21 Sep 2026 18:00:07 -0700 Subject: [PATCH 04/10] fix: address analytics review findings --- apps/web/core/analytics.test.ts | 17 +++++++ apps/web/core/analytics.ts | 4 +- .../debates/matchmaking/challenge-card.tsx | 2 + .../core/debates/matchmaking/claims-tab.tsx | 2 + .../matchmaking/filter-switch.test.tsx | 23 +++++++++ .../debates/matchmaking/filter-switch.tsx | 3 ++ .../core/debates/matchmaking/hub-analytics.ts | 10 ++++ .../debates/matchmaking/hub-filter-menu.tsx | 29 ++++++++++- .../matchmaking/incoming-request-card.tsx | 2 + .../matchmaking/incoming-request-popup.tsx | 1 + .../matchmaking/outbound-request-card.tsx | 2 + .../matchmaking/request-overflow-menu.tsx | 6 +++ .../debates/matchmaking/requests-tab.test.tsx | 34 +++++++++++++ .../core/debates/matchmaking/requests-tab.tsx | 1 + .../matchmaking/set-schedule-banner.test.tsx | 9 +++- .../matchmaking/set-schedule-banner.tsx | 4 ++ .../web/core/hooks/use-privy-sign-in.test.tsx | 12 +++++ apps/web/core/hooks/use-privy-sign-in.ts | 3 ++ .../entity-vote-buttons.batch.test.tsx | 49 ++++++++++++++++++- .../entity-page/entity-vote-buttons.tsx | 37 +++++++++++--- .../explore/email-capture-account-step.tsx | 32 ++++++------ .../explore/email-capture-popup.test.tsx | 24 ++++++++- 22 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 apps/web/core/debates/matchmaking/filter-switch.test.tsx create mode 100644 apps/web/core/debates/matchmaking/hub-analytics.ts diff --git a/apps/web/core/analytics.test.ts b/apps/web/core/analytics.test.ts index d04d940dd7..dbbe3326f5 100644 --- a/apps/web/core/analytics.test.ts +++ b/apps/web/core/analytics.test.ts @@ -337,4 +337,21 @@ describe('analytics', () => { 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', + }); + }); }); diff --git a/apps/web/core/analytics.ts b/apps/web/core/analytics.ts index 89168a00c8..d8f08c6f1b 100644 --- a/apps/web/core/analytics.ts +++ b/apps/web/core/analytics.ts @@ -1,5 +1,7 @@ 'use client'; +import { ID } from '~/core/id'; + export type AnalyticsProperties = Record; type AnalyticsIdentity = string | number | AnalyticsProperties; @@ -283,7 +285,7 @@ export function personProfileOpened( personEntityId?: string | null, properties: AnalyticsProperties = {} ) { - const resolvedPersonEntityId = personEntityId && personEntityId !== profileSpaceId ? personEntityId : null; + const resolvedPersonEntityId = personEntityId && !ID.equals(personEntityId, profileSpaceId) ? personEntityId : null; graphRelationshipFollowed(resolvedPersonEntityId ?? profileSpaceId, { source: 'person_profile', diff --git a/apps/web/core/debates/matchmaking/challenge-card.tsx b/apps/web/core/debates/matchmaking/challenge-card.tsx index d99a20fd2a..bdeeeaf47f 100644 --- a/apps/web/core/debates/matchmaking/challenge-card.tsx +++ b/apps/web/core/debates/matchmaking/challenge-card.tsx @@ -10,6 +10,7 @@ import { Text } from '~/design-system/text'; import type { DebateChallenge } from '../api'; import { useAcceptDebateChallenge, useRejectDebateChallenge } from '../hooks'; import { hubCardMotion } from './hub-motion'; +import { hubAnalyticsAttributes } from './hub-analytics'; import { HubPillButton } from './hub-pill-button'; import { RequestParties } from './request-parties'; import { useRequestCountdown } from './use-request-countdown'; @@ -75,6 +76,7 @@ export function DebateChallengeCard({ type="button" onClick={() => rejectChallenge.mutate(challenge.id)} disabled={busy} + {...hubAnalyticsAttributes('Cancel request', 'cancel_debate_request')} className="shrink-0 text-text underline transition-colors hover:text-grey-04 disabled:opacity-50" > {rejectChallenge.isPending ? 'Cancelling…' : 'Cancel request'} diff --git a/apps/web/core/debates/matchmaking/claims-tab.tsx b/apps/web/core/debates/matchmaking/claims-tab.tsx index 5186f993e4..501b9f0b9d 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.tsx @@ -1311,6 +1311,7 @@ export function SpaceTopicFilters({ // their adaptive placement. align="start" label={spaceMenuLabel} + analyticsName="Space" labelPending={spaceIds.length === 1 && !onlySpace && labelsLoading} options={spaceOptions} values={spaceIds} @@ -1328,6 +1329,7 @@ export function SpaceTopicFilters({ { + it('uses the stable setting name instead of state-dependent text', () => { + render(); + + expect(screen.getByRole('switch', { name: 'Matches only' })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate hub Matches only' + ); + expect(screen.getByRole('switch', { name: 'Matches only' })).toHaveAttribute( + 'data-geo-analytics-intent', + 'filter_debates_hub' + ); + }); +}); diff --git a/apps/web/core/debates/matchmaking/filter-switch.tsx b/apps/web/core/debates/matchmaking/filter-switch.tsx index ec19b8214e..3f230fafe5 100644 --- a/apps/web/core/debates/matchmaking/filter-switch.tsx +++ b/apps/web/core/debates/matchmaking/filter-switch.tsx @@ -2,6 +2,8 @@ import { Toggle } from '~/design-system/toggle'; +import { hubAnalyticsAttributes } from './hub-analytics'; + /** * A single on/off setting on a filter row, as a switch rather than a pill (GEO-2861). * @@ -29,6 +31,7 @@ export function FilterSwitch({ role="switch" aria-checked={checked} aria-label={label} + {...hubAnalyticsAttributes(label, 'filter_debates_hub')} onClick={() => onChange(!checked)} className="flex min-h-7 shrink-0 cursor-pointer items-center gap-1.5 text-metadata text-grey-04" > diff --git a/apps/web/core/debates/matchmaking/hub-analytics.ts b/apps/web/core/debates/matchmaking/hub-analytics.ts new file mode 100644 index 0000000000..b95ce673bf --- /dev/null +++ b/apps/web/core/debates/matchmaking/hub-analytics.ts @@ -0,0 +1,10 @@ +/** + * Stable metadata for debate-hub controls that cannot use {@link HubPillButton} because their + * visual treatment is different (filters, inline links, menus, and the schedule callout). + */ +export function hubAnalyticsAttributes(action: string, intent = 'debates_hub_action') { + return { + 'data-geo-analytics-label': `Debate hub ${action}`, + 'data-geo-analytics-intent': intent, + } as const; +} diff --git a/apps/web/core/debates/matchmaking/hub-filter-menu.tsx b/apps/web/core/debates/matchmaking/hub-filter-menu.tsx index cc0538b572..1a8f931d9c 100644 --- a/apps/web/core/debates/matchmaking/hub-filter-menu.tsx +++ b/apps/web/core/debates/matchmaking/hub-filter-menu.tsx @@ -16,6 +16,7 @@ import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; import { formatFacetCount } from './topic-facets'; +import { hubAnalyticsAttributes } from './hub-analytics'; import { useDelayedFlag } from './use-delayed-flag'; export type HubFilterOption = { @@ -31,6 +32,8 @@ export type HubFilterOption = { type Props = { label: string; + /** Stable hub filter dimension, independent of the current option. Omit on non-hub consumers. */ + analyticsName?: string; options: HubFilterOption[]; value: T; onChange: (value: T) => void; @@ -63,6 +66,7 @@ type Props = { */ export function HubFilterMenu({ label, + analyticsName, options, value, onChange, @@ -81,7 +85,12 @@ export function HubFilterMenu({ className="max-w-[280px]" // Space names come from the knowledge graph and can be long enough to burst the pill. trigger={ - } className="max-w-[160px]" aria-label={triggerAriaLabel}> + } + className="max-w-[160px]" + aria-label={triggerAriaLabel} + {...(analyticsName ? hubAnalyticsAttributes(`${analyticsName} filter`, 'filter_debates_hub') : {})} + > {labelPending ? ( // Sized to the pill's line box so the trigger doesn't resize when the name lands. @@ -99,6 +108,9 @@ export function HubFilterMenu({ // Picking a space nobody can name yet filters the list to something the viewer can't // read back off the trigger. The wait is short; the dead end isn't worth it. disabled={option.pending} + {...(analyticsName + ? hubAnalyticsAttributes(`${analyticsName} filter option`, 'filter_debates_hub') + : {})} onClick={() => { onChange(option.value); setOpen(false); @@ -145,6 +157,8 @@ export function HubFilterMenu({ type MultiProps = { /** Shown in the trigger pill: the one selected name, a count of them, or the "any" wording. */ label: string; + /** Stable hub filter dimension, independent of the current selection. Omit on non-hub consumers. */ + analyticsName?: string; options: HubFilterOption[]; values: T[]; onToggle: (value: T) => void; @@ -216,6 +230,7 @@ const SEARCH_FOCUS_DELAY_MS = 1; */ export function HubMultiFilterMenu({ label, + analyticsName, options, values, onToggle, @@ -356,7 +371,11 @@ export function HubMultiFilterMenu({ className="max-w-[280px]" viewportRef={setViewportNode} trigger={ - } className="max-w-[160px]"> + } + className="max-w-[160px]" + {...(analyticsName ? hubAnalyticsAttributes(`${analyticsName} filter`, 'filter_debates_hub') : {})} + > {labelPending ? ( ) : ( @@ -411,6 +430,9 @@ export function HubMultiFilterMenu({ {searching ? null : (