Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/ui/typography.tsx",

"src/providers/DialogProvider",
"src/providers/TourProvider/tourCompletion.ts",
"src/routes/EditorV2",

// 11-20 useCallback/useMemo
Expand Down
85 changes: 58 additions & 27 deletions src/components/Learn/FeaturedTours.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { useTourCompletion } from "@/providers/TourProvider/tourCompletion";
import { resetAllTourPipelineState } from "@/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState";
import { APP_ROUTES } from "@/routes/router";
import { tracking } from "@/utils/tracking";
Expand Down Expand Up @@ -80,41 +81,65 @@ export function FeaturedTours() {

<BlockStack gap="1">
{featured.map((tour) => (
<Button
<FeaturedTourButton
key={tour.id}
variant="ghost"
size="lg"
disabled={!tour.available}
onClick={() => startTour(tour.id)}
className="h-auto min-h-10 w-full justify-start whitespace-normal py-2 text-left"
{...tracking("learning_hub.tours.start", {
tour_id: tour.id,
})}
>
<InlineStack
gap="4"
align="space-between"
blockAlign="center"
wrap="nowrap"
fill
>
<FeaturedTourLabel tour={tour} />
<Icon
name="Play"
size="sm"
className="text-muted-foreground shrink-0"
aria-hidden="true"
/>
</InlineStack>
</Button>
tour={tour}
onStart={() => startTour(tour.id)}
/>
))}
</BlockStack>
</BlockStack>
</div>
);
}

function FeaturedTourLabel({ tour }: { tour: FeaturedTour }) {
function FeaturedTourButton({
tour,
onStart,
}: {
tour: FeaturedTour;
onStart: () => void;
}) {
const completed = useTourCompletion(tour.id);

return (
<Button
variant="ghost"
size="lg"
disabled={!tour.available}
onClick={onStart}
className="h-auto min-h-10 w-full justify-start whitespace-normal py-2 text-left"
{...tracking("learning_hub.tours.start", {
tour_id: tour.id,
is_restart: completed,
})}
>
<InlineStack
gap="4"
align="space-between"
blockAlign="center"
wrap="nowrap"
fill
>
<FeaturedTourLabel tour={tour} completed={completed} />
<Icon
name={completed ? "RotateCcw" : "Play"}
size="sm"
className="text-muted-foreground shrink-0"
aria-hidden="true"
/>
</InlineStack>
</Button>
);
}

function FeaturedTourLabel({
tour,
completed,
}: {
tour: FeaturedTour;
completed: boolean;
}) {
return (
<BlockStack className="min-w-0">
<InlineStack gap="2" blockAlign="center">
Expand All @@ -130,6 +155,12 @@ function FeaturedTourLabel({ tour }: { tour: FeaturedTour }) {
{tour.tag}
</Badge>
)}
{completed && (
<Badge size="sm" variant="outline" className="text-green-600">
Comment thread
camielvs marked this conversation as resolved.
<Icon name="Check" size="xs" aria-hidden="true" />
Completed
</Badge>
)}
{!tour.available && (
<Badge size="sm" variant="outline">
Coming soon
Expand Down
21 changes: 18 additions & 3 deletions src/components/Learn/ToursLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { useTourCompletion } from "@/providers/TourProvider/tourCompletion";
import { resetAllTourPipelineState } from "@/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState";
import { APP_ROUTES } from "@/routes/router";
import { tracking } from "@/utils/tracking";
Expand All @@ -30,6 +31,7 @@ import { getTour } from "./tours/registry";

function TourCard({ tour }: { tour: Tour }) {
const isAvailable = getTour(tour.id) !== undefined;
const completed = useTourCompletion(tour.id);
const navigate = useNavigate();
const queryClient = useQueryClient();

Expand All @@ -55,6 +57,12 @@ function TourCard({ tour }: { tour: Tour }) {
<Badge size="sm" variant="secondary">
{tour.area}
</Badge>
{completed && (
<Badge size="sm" variant="outline" className="text-green-600">
Comment thread
camielvs marked this conversation as resolved.
<Icon name="Check" size="xs" aria-hidden="true" />
Completed
</Badge>
)}
<Text size="xs" tone="subdued">
{tour.duration}
</Text>
Expand All @@ -64,10 +72,17 @@ function TourCard({ tour }: { tour: Tour }) {
size="sm"
variant="ghost"
onClick={startTour}
{...tracking("learning_hub.tours.start", { tour_id: tour.id })}
{...tracking("learning_hub.tours.start", {
tour_id: tour.id,
is_restart: completed,
})}
>
Start tour
<Icon name="Play" size="sm" aria-hidden="true" />
{completed ? "Restart" : "Start tour"}
<Icon
name={completed ? "RotateCcw" : "Play"}
size="sm"
aria-hidden="true"
/>
</Button>
) : (
<Button
Expand Down
118 changes: 118 additions & 0 deletions src/providers/TourProvider/TourTelemetryBridge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { TourDefinition } from "@/components/Learn/tours/registry";

import { TourTelemetryBridge } from "./TourTelemetryBridge";

const track = vi.fn();
const recordCompletion = vi.fn(() => ({
completionCount: 1,
previouslyCompleted: false,
}));

let tourState: { currentStep: number };
let tour: TourDefinition | null;

vi.mock("@reactour/tour", () => ({
useTour: () => tourState,
}));

vi.mock("@/providers/AnalyticsProvider", () => ({
useAnalytics: () => ({ track }),
}));

vi.mock("./TourModeContext", () => ({
useTourMode: () => (tour ? { tour } : null),
}));

vi.mock("./tourCompletion", () => ({
useRecordTourCompletion: () => recordCompletion,
useTourCompletions: () => ({ data: undefined }),
}));

const TOUR: TourDefinition = {
id: "first-pipeline",
steps: [
{ selector: "#a", content: "a", interaction: "add-task" },
{ selector: "#b", content: "b" },
{ selector: "#c", content: "c" },
],
};

function eventsOfType(type: string) {
return track.mock.calls.filter(([actionType]) => actionType === type);
}

beforeEach(() => {
vi.clearAllMocks();
tour = TOUR;
tourState = { currentStep: 0 };
});

afterEach(() => {
vi.clearAllMocks();
});

describe("TourTelemetryBridge", () => {
it("fires step_viewed once per step reached", () => {
const { rerender } = render(<TourTelemetryBridge />);
tourState = { currentStep: 1 };
rerender(<TourTelemetryBridge />);
tourState = { currentStep: 1 };
rerender(<TourTelemetryBridge />);

const steps = eventsOfType("learning_hub.tours.step_viewed");
expect(steps).toHaveLength(2);
expect(steps[0][1]).toMatchObject({
tour_id: "first-pipeline",
step_index: 0,
step_count: 3,
interaction: "add-task",
});
expect(steps[1][1]).toMatchObject({
step_index: 1,
interaction: undefined,
});
});

it("marks completion and fires completed on reaching the last step", () => {
const { rerender } = render(<TourTelemetryBridge />);
tourState = { currentStep: 2 };
rerender(<TourTelemetryBridge />);

expect(recordCompletion).toHaveBeenCalledTimes(1);
const completed = eventsOfType("learning_hub.tours.completed");
expect(completed).toHaveLength(1);
expect(completed[0][1]).toMatchObject({
tour_id: "first-pipeline",
step_count: 3,
completion_count: 1,
});
});

it("fires exited with furthest_step when unmounted before completing", () => {
const { rerender, unmount } = render(<TourTelemetryBridge />);
tourState = { currentStep: 1 };
rerender(<TourTelemetryBridge />);
unmount();

const exited = eventsOfType("learning_hub.tours.exited");
expect(exited).toHaveLength(1);
expect(exited[0][1]).toMatchObject({
tour_id: "first-pipeline",
furthest_step: 1,
step_count: 3,
percent_complete: 67,
});
});

it("does not fire exited when the tour completed", () => {
const { rerender, unmount } = render(<TourTelemetryBridge />);
tourState = { currentStep: 2 };
rerender(<TourTelemetryBridge />);
unmount();

expect(eventsOfType("learning_hub.tours.exited")).toHaveLength(0);
});
});
82 changes: 82 additions & 0 deletions src/providers/TourProvider/TourTelemetryBridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useTour } from "@reactour/tour";
import { useEffect, useRef } from "react";

import { useAnalytics } from "@/providers/AnalyticsProvider";

import { useRecordTourCompletion, useTourCompletions } from "./tourCompletion";
import { useTourMode } from "./TourModeContext";

export function TourTelemetryBridge() {
const { currentStep } = useTour();
const tourMode = useTourMode();
const { track } = useAnalytics();
const recordCompletion = useRecordTourCompletion();
const { data: completions } = useTourCompletions();

const tour = tourMode?.tour;
const stepCount = tour?.steps.length ?? 0;

const startedAtRef = useRef<number>(Date.now());
const furthestRef = useRef(0);
const seenStepsRef = useRef<Set<number>>(new Set());
const completedRef = useRef(false);
const previouslyCompletedRef = useRef(false);

useEffect(() => {
if (!completedRef.current && tour) {
previouslyCompletedRef.current = Boolean(completions?.[tour.id]);
}
}, [completions, tour]);

useEffect(() => {
if (!tour || stepCount === 0) return;

if (currentStep > furthestRef.current) {
furthestRef.current = currentStep;
}

if (!seenStepsRef.current.has(currentStep)) {
seenStepsRef.current.add(currentStep);
track("learning_hub.tours.step_viewed", {
tour_id: tour.id,
step_index: currentStep,
step_count: stepCount,
interaction: tour.steps[currentStep]?.interaction,
});
}

if (currentStep >= stepCount - 1 && !completedRef.current) {
completedRef.current = true;
const { completionCount, previouslyCompleted } = recordCompletion(
tour.id,
);
previouslyCompletedRef.current = previouslyCompleted;
track("learning_hub.tours.completed", {
tour_id: tour.id,
step_count: stepCount,
completion_count: completionCount,
previously_completed: previouslyCompleted,
duration_ms: Date.now() - startedAtRef.current,
});
}
}, [currentStep, tour, stepCount, track, recordCompletion]);
Comment thread
camielvs marked this conversation as resolved.

useEffect(() => {
if (!tour) return undefined;
return () => {
if (completedRef.current) return;
const furthest = furthestRef.current;
track("learning_hub.tours.exited", {
tour_id: tour.id,
furthest_step: furthest,
step_count: stepCount,
percent_complete:
stepCount > 0 ? Math.round(((furthest + 1) / stepCount) * 100) : 0,
duration_ms: Date.now() - startedAtRef.current,
previously_completed: previouslyCompletedRef.current,
});
};
}, [tour, stepCount, track]);

return null;
}
Loading
Loading