From db7704e47234d77c36d53889c8650512187221e1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 24 Feb 2026 17:15:07 -0500 Subject: [PATCH 1/7] add support for skipEngagementTracking debug setting to isolate guide engagement in the client side only --- packages/client/src/clients/guide/client.ts | 31 +- packages/client/src/clients/guide/types.ts | 1 + .../client/test/clients/guide/guide.test.ts | 361 ++++++++++++++++++ .../Toolbar/V2/GuideContextDetails.tsx | 34 +- 4 files changed, 425 insertions(+), 2 deletions(-) diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index acb4027ce..16566604e 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -588,7 +588,11 @@ export class KnockGuideClient { this.store.setState((state) => ({ ...state, - debug: { ...debugOpts, debugging: true }, + debug: { + skipEngagementTracking: true, + ...debugOpts, + debugging: true, + }, })); if (shouldRefetch) { @@ -970,6 +974,13 @@ export class KnockGuideClient { }); if (!updatedStep) return; + if (this.shouldSkipEngagementApi()) { + this.knock.log( + "[Guide] Skipping engagement API call for markAsSeen (debug mode)", + ); + return updatedStep; + } + const params = { ...this.buildEngagementEventBaseParams(guide, updatedStep), content: updatedStep.content, @@ -1000,6 +1011,13 @@ export class KnockGuideClient { }); if (!updatedStep) return; + if (this.shouldSkipEngagementApi()) { + this.knock.log( + "[Guide] Skipping engagement API call for markAsInteracted (debug mode)", + ); + return updatedStep; + } + const params = { ...this.buildEngagementEventBaseParams(guide, updatedStep), metadata, @@ -1025,6 +1043,13 @@ export class KnockGuideClient { }); if (!updatedStep) return; + if (this.shouldSkipEngagementApi()) { + this.knock.log( + "[Guide] Skipping engagement API call for markAsArchived (debug mode)", + ); + return updatedStep; + } + const params = this.buildEngagementEventBaseParams(guide, updatedStep); this.knock.user.markGuideStepAs( @@ -1038,6 +1063,10 @@ export class KnockGuideClient { return updatedStep; } + private shouldSkipEngagementApi(): boolean { + return !!this.store.state.debug?.skipEngagementTracking; + } + // // Helpers // diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index 96903b1b3..725871ce4 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -233,6 +233,7 @@ export type DebugState = { debugging?: boolean; forcedGuideKey?: string | null; previewSessionId?: string | null; + skipEngagementTracking?: boolean; }; export type StoreState = { diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 88d5a7785..8c0f15134 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -950,6 +950,339 @@ describe("KnockGuideClient", () => { const newState = stateUpdateFn(stateWithGuides); expect(newState.guideGroupDisplayLogs).toEqual({}); }); + + test("markAsSeen skips API call when skipEngagementTracking is true", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: true, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + const result = await client.markAsSeen(freshMockGuide, freshMockStep); + + expect(result).toBeDefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsSeen sends API call when skipEngagementTracking is false", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: false, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + await client.markAsSeen(freshMockGuide, freshMockStep); + + expect(mockKnock.user.markGuideStepAs).toHaveBeenCalledWith( + "seen", + expect.any(Object), + ); + }); + + test("markAsInteracted skips API call when skipEngagementTracking is true", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: true, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + const result = await client.markAsInteracted( + freshMockGuide, + freshMockStep, + { action: "clicked" }, + ); + + expect(result).toBeDefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsInteracted sends API call when skipEngagementTracking is false", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: false, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + await client.markAsInteracted(freshMockGuide, freshMockStep, { + action: "clicked", + }); + + expect(mockKnock.user.markGuideStepAs).toHaveBeenCalledWith( + "interacted", + expect.any(Object), + ); + }); + + test("markAsArchived skips API call when skipEngagementTracking is true", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: true, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + const result = await client.markAsArchived( + freshMockGuide, + freshMockStep, + ); + + expect(result).toBeDefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsArchived sends API call when skipEngagementTracking is false", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const freshMockStep = { + ref: "step_1", + schema_key: "test", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + id: "msg_123", + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const freshMockGuide = { + ...mockGuide, + steps: [freshMockStep], + getStep: vi.fn().mockReturnValue(freshMockStep), + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshMockGuide.key]: freshMockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + skipEngagementTracking: false, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + await client.markAsArchived(freshMockGuide, freshMockStep); + + expect(mockKnock.user.markGuideStepAs).toHaveBeenCalledWith( + "archived", + expect.any(Object), + ); + }); }); describe("cleanup", () => { @@ -3873,6 +4206,34 @@ describe("KnockGuideClient", () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(subscribeSpy).not.toHaveBeenCalled(); }); + + test("defaults skipEngagementTracking to true", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + vi.spyOn(client, "fetch").mockImplementation(() => + Promise.resolve({ status: "ok" }), + ); + vi.spyOn(client, "subscribe").mockImplementation(() => {}); + + client.setDebug(); + + expect(client.store.state.debug!.skipEngagementTracking).toBe(true); + }); + + test("allows overriding skipEngagementTracking to false", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + vi.spyOn(client, "fetch").mockImplementation(() => + Promise.resolve({ status: "ok" }), + ); + vi.spyOn(client, "subscribe").mockImplementation(() => {}); + + client.setDebug({ skipEngagementTracking: false }); + + expect(client.store.state.debug!.skipEngagementTracking).toBe(false); + }); }); describe("unsetDebug", () => { diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx index 5b60e019e..ff8ada74c 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx @@ -1,4 +1,5 @@ import { useGuideContext, useStore } from "@knocklabs/react-core"; +import { Button } from "@telegraph/button"; import { Box, Stack } from "@telegraph/layout"; import { Text } from "@telegraph/typography"; import { ChevronDown, ChevronRight } from "lucide-react"; @@ -8,7 +9,14 @@ export const GuideContextDetails = () => { const { client } = useGuideContext(); const [isExpanded, setIsExpanded] = React.useState(false); - const defaultGroup = useStore(client.store, (state) => state.guideGroups[0]); + const { defaultGroup, debugSettings } = useStore(client.store, (state) => { + return { + defaultGroup: state.guideGroups[0], + debugSettings: { + skipEngagementTracking: !!state.debug?.skipEngagementTracking, + }, + }; + }); const displayInterval = defaultGroup?.display_interval ?? null; return ( @@ -30,6 +38,30 @@ export const GuideContextDetails = () => { {isExpanded && ( + + + Client-only engagement + + + + Throttle From 63eb609fdff8c7e4c075a2cae67ea9dd516670f5 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 24 Feb 2026 19:12:58 -0500 Subject: [PATCH 2/7] consider archived_at timestamps in guides when evaluating archived status --- .../Toolbar/V2/useInspectGuideClientStore.ts | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts b/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts index f11fadd6f..188c7c271 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts +++ b/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts @@ -294,36 +294,38 @@ const toSelectableStatus = ( }; const toIneligibilityStatus = ( - marker: KnockGuideIneligibilityMarker, -): Partial | undefined => { - switch (marker.reason) { - case "not_in_target_audience": - case "target_conditions_not_met": - return { - targetable: { - status: false, - reason: marker.reason, - message: marker.message, - }, - }; - - case "marked_as_archived": - return { - archived: { - status: true, - }, - }; - - case "guide_not_active": - return { - active: { - status: false, - }, - }; - - default: - return undefined; + guide: KnockGuide, + marker?: KnockGuideIneligibilityMarker, +): Partial => { + const statuses: Partial = {}; + + if ( + marker?.reason === "not_in_target_audience" || + marker?.reason === "target_conditions_not_met" + ) { + statuses.targetable = { + status: false, + reason: marker.reason, + message: marker.message, + }; + } + + if ( + marker?.reason === "marked_as_archived" || + (guide.steps || []).every((s) => !!s.message.archived_at) + ) { + statuses.archived = { + status: true, + }; } + + if (marker?.reason === "guide_not_active") { + statuses.active = { + status: false, + }; + } + + return statuses; }; const resolveIsEligible = ({ @@ -353,7 +355,7 @@ const annotateGuide = ( ): AnnotatedGuide => { const { ineligibleGuides, location } = snapshot; const marker = ineligibleGuides[guide.key]; - const ineligiblity = marker ? toIneligibilityStatus(marker) : undefined; + const ineligiblity = toIneligibilityStatus(guide, marker); const statuses: AnnotatedStatuses = { // isEligible: From 3df7a4ba6b5ddd0d26fbd339b9fbf49440e37b3b Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 24 Feb 2026 19:28:08 -0500 Subject: [PATCH 3/7] add tests --- .../V2/useInspectGuideClientStore.test.ts | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts b/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts index d1b5ad928..55c59b50a 100644 --- a/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts +++ b/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts @@ -45,6 +45,25 @@ vi.mock("@knocklabs/react-core", async () => { }; }); +const makeStep = (overrides: Record = {}) => ({ + ref: "step-1", + schema_key: "schema-1", + schema_semver: "1.0.0", + schema_variant_key: "variant-1", + message: { + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + ...overrides, +}); + const makeGuide = (overrides: Record = {}) => ({ __typename: "Guide", id: "guide-1", @@ -53,7 +72,7 @@ const makeGuide = (overrides: Record = {}) => ({ type: "banner", semver: "1.0.0", active: true, - steps: [], + steps: [makeStep()], activation_url_rules: [], activation_url_patterns: [], bypass_global_group_limit: false, @@ -277,6 +296,108 @@ describe("useInspectGuideClientStore", () => { expect(annotated.annotation.archived).toEqual({ status: true }); }); + // ----- Guide annotation: archived via step archived_at timestamps ----- + + test("annotates a guide as archived when all steps have archived_at timestamps (no marker)", () => { + const guide = makeGuide({ + key: "g1", + active: true, + steps: [ + makeStep({ + ref: "step-1", + message: { archived_at: "2024-06-01T00:00:00Z" }, + }), + makeStep({ + ref: "step-2", + message: { archived_at: "2024-06-02T00:00:00Z" }, + }), + ], + }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect()!; + const annotated = result.guides[0] as AnnotatedGuide; + expect(annotated.annotation.isEligible).toBe(false); + expect(annotated.annotation.archived).toEqual({ status: true }); + // No marker means targetable is still true + expect(annotated.annotation.targetable).toEqual({ status: true }); + }); + + test("does not mark guide as archived when only some steps have archived_at", () => { + const guide = makeGuide({ + key: "g1", + active: true, + steps: [ + makeStep({ + ref: "step-1", + message: { archived_at: "2024-06-01T00:00:00Z" }, + }), + makeStep({ + ref: "step-2", + message: { archived_at: null }, + }), + ], + }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect()!; + const annotated = result.guides[0] as AnnotatedGuide; + expect(annotated.annotation.isEligible).toBe(true); + expect(annotated.annotation.archived).toEqual({ status: false }); + }); + + test("annotates a guide as archived when single step has archived_at", () => { + const guide = makeGuide({ + key: "g1", + active: true, + steps: [ + makeStep({ + ref: "step-1", + message: { archived_at: "2024-06-01T00:00:00Z" }, + }), + ], + }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect()!; + const annotated = result.guides[0] as AnnotatedGuide; + expect(annotated.annotation.isEligible).toBe(false); + expect(annotated.annotation.archived).toEqual({ status: true }); + }); + + test("annotates a guide as archived when marker says marked_as_archived even if steps have no archived_at", () => { + const guide = makeGuide({ + key: "g1", + active: true, + steps: [ + makeStep({ ref: "step-1", message: { archived_at: null } }), + ], + }); + const marker = makeMarker("g1", "marked_as_archived", "Already dismissed"); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: { g1: marker }, + }); + + const result = renderInspect()!; + const annotated = result.guides[0] as AnnotatedGuide; + expect(annotated.annotation.isEligible).toBe(false); + expect(annotated.annotation.archived).toEqual({ status: true }); + }); + // ----- Guide annotation: unrecognized marker reason ----- test("treats an unrecognized marker reason as still targetable and not archived", () => { From a55f803982899f0de8a23bfe254d61e102d282b2 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 25 Feb 2026 14:35:06 -0500 Subject: [PATCH 4/7] add tooltip --- .../Toolbar/V2/GuideContextDetails.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx index ff8ada74c..a08c06621 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx @@ -1,8 +1,10 @@ import { useGuideContext, useStore } from "@knocklabs/react-core"; import { Button } from "@telegraph/button"; +import { Icon } from "@telegraph/icon"; import { Box, Stack } from "@telegraph/layout"; +import { Tooltip } from "@telegraph/tooltip"; import { Text } from "@telegraph/typography"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Info } from "lucide-react"; import * as React from "react"; export const GuideContextDetails = () => { @@ -31,7 +33,7 @@ export const GuideContextDetails = () => { onClick={() => setIsExpanded((prev) => !prev)} > - Details + More {isExpanded ? : } @@ -45,9 +47,14 @@ export const GuideContextDetails = () => { px="2" borderTop="px" > - - Client-only engagement - + + + Client-only engagement + + + + +