From f806d6aeac12cd802e2c8308c17bc11446880406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 19:00:43 +0200 Subject: [PATCH 1/2] feat(onboarding): activation checklist, instant verify, second-project flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Project.firstEventAt set exactly once by the worker on the project's first event (cached read + conditional update keeps it a cheap no-op after). - Verify page flips instantly via the existing /live/events websocket; the poll drops to a 10s fallback. - Add-project modal now offers 'Set up tracking' into the same connect -> verify steps as onboarding instead of dead-ending on a toast. - Activation checklist card on the project overview (first event, first report, invite teammate) derived from a new project.activationStatus tRPC endpoint — no new state machine; dismissible per project. - Dashboard telemetry: op.identify() for signed-in users plus onboarding funnel events (project created, verify viewed, first event verified, checklist interactions) — the activation funnel was previously unmeasurable. - Trial emails get the onboarding unsubscribe category (they bypassed suppression and had no List-Unsubscribe header). - dataHealth no-data notice skips orgs still in the onboarding drip, whose day-2/6 emails already handle the stuck-install nudge. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- .../onboarding/activation-checklist.tsx | 144 ++++++++++++++++++ apps/start/src/modals/add-project.tsx | 9 +- apps/start/src/routes/__root.tsx | 23 +++ .../_app.$organizationId.$projectId.index.tsx | 2 + .../_steps.onboarding.$projectId.verify.tsx | 27 +++- .../src/routes/_steps.onboarding.project.tsx | 2 + apps/worker/src/jobs/cron.data-health.ts | 12 +- apps/worker/src/jobs/events.incoming-event.ts | 20 +++ .../migration.sql | 4 + packages/db/prisma/schema.prisma | 3 + packages/email/src/emails/index.tsx | 4 + packages/trpc/src/routers/project.ts | 38 +++++ 12 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 apps/start/src/components/onboarding/activation-checklist.tsx create mode 100644 packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql diff --git a/apps/start/src/components/onboarding/activation-checklist.tsx b/apps/start/src/components/onboarding/activation-checklist.tsx new file mode 100644 index 000000000..0a1f0d3b9 --- /dev/null +++ b/apps/start/src/components/onboarding/activation-checklist.tsx @@ -0,0 +1,144 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { CheckIcon, XIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { cn } from '@/utils/cn'; +import { op } from '@/utils/op'; + +// Getting-started checklist shown on the project overview until every step is +// done (or the user dismisses it). Steps derive from existing data — no state +// machine: first event (Project.firstEventAt), first report, invited teammate. + +const dismissKey = (projectId: string) => + `op-activation-checklist-dismissed:${projectId}`; + +const readDismissed = (projectId: string) => { + try { + return localStorage.getItem(dismissKey(projectId)) === '1'; + } catch { + return true; + } +}; + +export default function ActivationChecklist() { + const { organizationId, projectId } = useAppParams(); + const trpc = useTRPC(); + + // Hidden until mounted so SSR and the first client render agree. + const [dismissed, setDismissed] = useState(true); + useEffect(() => { + setDismissed(readDismissed(projectId)); + }, [projectId]); + + const statusQuery = useQuery( + trpc.project.activationStatus.queryOptions( + { projectId }, + { enabled: !dismissed } + ) + ); + const status = statusQuery.data; + + if (dismissed || !status) { + return null; + } + + const steps = [ + { + key: 'first-event', + label: 'Install the SDK and receive your first event', + done: !!status.firstEventAt, + action: ( + + ), + }, + { + key: 'first-report', + label: 'Create your first report', + done: status.hasReport, + action: ( + + ), + }, + { + key: 'invite-teammate', + label: 'Invite a teammate', + done: status.hasTeammate, + action: ( + + ), + }, + ]; + + const remaining = steps.filter((step) => !step.done); + if (remaining.length === 0) { + return null; + } + + const dismiss = () => { + op.track('activation_checklist_dismissed', { projectId }); + try { + localStorage.setItem(dismissKey(projectId), '1'); + } catch { + // Storage unavailable — the checklist just shows again next session. + } + setDismissed(true); + }; + + return ( +
+
+
+ Get set up ({steps.length - remaining.length}/{steps.length}) +
+ +
+
+ {steps.map((step) => ( +
+
+
+ {step.done && } +
+ + {step.label} + +
+ {!step.done && step.action} +
+ ))} +
+
+ ); +} diff --git a/apps/start/src/modals/add-project.tsx b/apps/start/src/modals/add-project.tsx index 860f3a1c3..dd601cf43 100644 --- a/apps/start/src/modals/add-project.tsx +++ b/apps/start/src/modals/add-project.tsx @@ -109,9 +109,16 @@ export default function AddProject() { )} - + {/* Route through the same connect -> verify steps as onboarding so + a second project doesn't skip the install instructions. */} + ) : ( diff --git a/apps/start/src/routes/__root.tsx b/apps/start/src/routes/__root.tsx index 716025f5b..47b1cdc1e 100644 --- a/apps/start/src/routes/__root.tsx +++ b/apps/start/src/routes/__root.tsx @@ -2,7 +2,9 @@ import { createRootRouteWithContext, HeadContent, Scripts, + useRouteContext, } from '@tanstack/react-router'; +import { useEffect } from 'react'; import 'flag-icons/css/flag-icons.min.css'; import 'katex/dist/katex.min.css'; @@ -93,6 +95,26 @@ export const Route = createRootRouteWithContext()({ pendingComponent: FullPageLoadingState, }); +// Tie dashboard events to the signed-in user so activation funnels +// (signup -> project created -> first event -> report) can be measured. +function OpIdentify() { + const context = useRouteContext({ strict: false }); + const user = context.session?.user; + + useEffect(() => { + if (user?.id) { + op.identify({ + profileId: user.id, + email: user.email, + firstName: user.firstName ?? undefined, + lastName: user.lastName ?? undefined, + }); + } + }, [user?.id, user?.email, user?.firstName, user?.lastName]); + + return null; +} + function RootDocument({ children }: { children: React.ReactNode }) { useSessionExtension(); @@ -102,6 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { + {children} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx index c89425b2c..4dd06cc34 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router'; import { LazyComponent } from '@/components/lazy-component'; +import ActivationChecklist from '@/components/onboarding/activation-checklist'; import { useRangePageContext } from '@/hooks/use-page-context-helpers'; import { OverviewFilterButton, @@ -57,6 +58,7 @@ function ProjectDashboard() {
+ diff --git a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx index f600605c4..abe43ebcd 100644 --- a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx +++ b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, Link, redirect } from '@tanstack/react-router'; import { BoxSelectIcon } from 'lucide-react'; import { ButtonContainer } from '@/components/button-container'; @@ -7,8 +7,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state'; import VerifyListener from '@/components/onboarding/onboarding-verify-listener'; import { VerifyFaq } from '@/components/onboarding/verify-faq'; import { LinkButton } from '@/components/ui/button'; +import { useEffect } from 'react'; +import useWS from '@/hooks/use-ws'; import { useTRPC } from '@/integrations/trpc/react'; import { cn } from '@/lib/utils'; +import { op } from '@/utils/op'; import { createEntityTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({ @@ -34,15 +37,35 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({ function Component() { const { projectId } = Route.useParams(); const trpc = useTRPC(); + const queryClient = useQueryClient(); const { data: events } = useQuery( trpc.event.events.queryOptions( { projectId }, { - refetchInterval: 2500, + // The live websocket below flips the verifier instantly; this poll is + // only a fallback for when the socket can't connect. + refetchInterval: 10_000, } ) ); + // Refetch the event list the moment an event arrives instead of waiting for + // the next poll — same channel the in-app live event feed uses. + useWS(`/live/events/${projectId}`, () => { + queryClient.invalidateQueries( + trpc.event.events.queryFilter({ projectId }) + ); + }); const isVerified = events?.data && events.data.length > 0; + + useEffect(() => { + op.track('onboarding_verify_viewed', { projectId }); + }, [projectId]); + + useEffect(() => { + if (isVerified) { + op.track('onboarding_first_event_verified', { projectId }); + } + }, [isVerified, projectId]); const { data: project } = useQuery( trpc.project.getProjectWithClients.queryOptions({ projectId }) ); diff --git a/apps/start/src/routes/_steps.onboarding.project.tsx b/apps/start/src/routes/_steps.onboarding.project.tsx index a6e616259..108617504 100644 --- a/apps/start/src/routes/_steps.onboarding.project.tsx +++ b/apps/start/src/routes/_steps.onboarding.project.tsx @@ -27,6 +27,7 @@ import { Label } from '@/components/ui/label'; import { useClientSecret } from '@/hooks/use-client-secret'; import { handleError, useTRPC } from '@/integrations/trpc/react'; import { cn } from '@/utils/cn'; +import { op } from '@/utils/op'; const validateSearch = z.object({ inviteId: z.string().optional(), @@ -74,6 +75,7 @@ function Component() { trpc.onboarding.project.mutationOptions({ onError: handleError, onSuccess(res) { + op.track('onboarding_project_created', { projectId: res.projectId }); queryClient.invalidateQueries(trpc.organization.list.queryFilter()); setSecret(res.secret); navigate({ diff --git a/apps/worker/src/jobs/cron.data-health.ts b/apps/worker/src/jobs/cron.data-health.ts index 2dc9c885d..7ca23ece5 100644 --- a/apps/worker/src/jobs/cron.data-health.ts +++ b/apps/worker/src/jobs/cron.data-health.ts @@ -65,7 +65,9 @@ export async function dataHealthCronJob() { organizationId: true, noDataNotifiedAt: true, dataStoppedNotifiedAt: true, - organization: { select: { id: true, subscriptionState: true } }, + organization: { + select: { id: true, subscriptionState: true, onboarding: true }, + }, }, }); @@ -81,10 +83,12 @@ export async function dataHealthCronJob() { if (!lastEventAt) { // Never received an event. One notice per project, after the grace - // period. (The onboarding drip already nudges brand-new orgs, so this - // mainly catches additional projects and broken installs.) + // period. Orgs still in the onboarding email drip are excluded — its + // day-2/6 emails already carry "stuck on the install?" variants, so this + // notice targets post-onboarding orgs and additional projects. + const inOnboardingDrip = project.organization.onboarding !== 'completed'; const oldEnough = now - project.createdAt.getTime() > NO_DATA_AFTER_MS; - if (oldEnough && !project.noDataNotifiedAt) { + if (oldEnough && !project.noDataNotifiedAt && !inOnboardingDrip) { const entry = byOrg.get(project.organizationId) ?? { organizationId: project.organizationId, noData: [], diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts index 0cac12864..62e4b8191 100644 --- a/apps/worker/src/jobs/events.incoming-event.ts +++ b/apps/worker/src/jobs/events.incoming-event.ts @@ -4,6 +4,7 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db'; import { checkNotificationRulesForEvent, createEvent, + db, getProjectByIdCached, matchEvent, sessionBuffer, @@ -35,6 +36,24 @@ async function isEventExcludedByProjectFilter( return eventExcludeFilters.some((filter) => matchEvent(payload, filter)); } +/** + * Records the project's first-ever event timestamp exactly once. The cached + * project read makes this a no-op on every event after the first; the + * conditional update keeps concurrent workers idempotent. + */ +async function markFirstEvent(projectId: string, logger: ILogger) { + const project = await getProjectByIdCached(projectId); + if (!project || project.firstEventAt) { + return; + } + await db.project.updateMany({ + where: { id: projectId, firstEventAt: null }, + data: { firstEventAt: new Date() }, + }); + await getProjectByIdCached.clear(projectId); + logger.info({ projectId }, 'Project received its first event'); +} + async function createEventAndNotify( payload: IServiceCreateEventPayload, logger: ILogger, @@ -53,6 +72,7 @@ async function createEventAndNotify( const [event] = await Promise.all([ createEvent(payload), checkNotificationRulesForEvent(payload).catch(() => null), + markFirstEvent(projectId, logger).catch(() => null), ]); return event; } diff --git a/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql new file mode 100644 index 000000000..bc7169354 --- /dev/null +++ b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql @@ -0,0 +1,4 @@ +-- Set once by the worker when a project's first event arrives. Powers the +-- activation checklist and onboarding verification. +ALTER TABLE "projects" + ADD COLUMN "firstEventAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3da985e29..d5a69d0e6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -254,6 +254,9 @@ model Project { allowUnsafeRevenueTracking Boolean @default(false) /// [IPrismaProjectFilters] filters Json @default("[]") + // Set once when the project's first event arrives (activation checklist, + // onboarding). Never updated after that. + firstEventAt DateTime? // Data-health notice markers set by the dataHealth cron. `noDataNotifiedAt`: // told the org this project never received events. `dataStoppedNotifiedAt`: // told them the event flow stalled — compared against the last event time, so diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index fc4d9e972..60a5ea258 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -76,11 +76,15 @@ export const templates = { : 'Your OpenPanel trial ends soon', Component: OnboardingTrialEnding, schema: zOnboardingTrialEnding, + // Without a category these bypassed unsubscribe entirely (no suppression + // check, no List-Unsubscribe header). + category: 'onboarding' as const, }, 'onboarding-trial-ended': { subject: () => 'Your trial ended, dashboard is locked', Component: OnboardingTrialEnded, schema: zOnboardingTrialEnded, + category: 'onboarding' as const, }, 'weekly-digest': { subject: (data: z.infer) => diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index 34819c733..9b65827aa 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -38,6 +38,44 @@ export const projectRouter = createTRPCRouter({ return getProjectWithClients(projectId); }), + // Powers the activation checklist on the project overview: has the project + // received data, built a report, and invited a teammate yet? + activationStatus: protectedProcedure + .input( + z.object({ + projectId: z.string(), + }) + ) + .query(async ({ input: { projectId }, ctx }) => { + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId, + }); + + if (!access) { + throw new TRPCForbiddenError('You do not have access to this project'); + } + + const project = await db.project.findUniqueOrThrow({ + where: { id: projectId }, + select: { firstEventAt: true, organizationId: true, createdAt: true }, + }); + + const [reportCount, memberCount] = await Promise.all([ + db.report.count({ where: { projectId } }), + db.member.count({ + where: { organizationId: project.organizationId }, + }), + ]); + + return { + firstEventAt: project.firstEventAt, + projectCreatedAt: project.createdAt, + hasReport: reportCount > 0, + hasTeammate: memberCount > 1, + }; + }), + list: protectedProcedure .input( z.object({ From beecc51f5caf1c510f1d0fa5a0eacc844ba6cbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 20:08:04 +0200 Subject: [PATCH 2/2] fix(hooks): useWS invokes the latest onMessage callback The debounced wrapper memoized the first render's callback, so a path change without unmount (e.g. switching projects on the verify page) reconnected the socket but kept calling a handler closed over the old path's state. Keep the callback in a ref and route the memoized wrapper through it. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- apps/start/src/hooks/use-ws.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/start/src/hooks/use-ws.ts b/apps/start/src/hooks/use-ws.ts index b3f01b0eb..fe74da522 100644 --- a/apps/start/src/hooks/use-ws.ts +++ b/apps/start/src/hooks/use-ws.ts @@ -1,5 +1,5 @@ import debounce from 'lodash.debounce'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket'; import { getSuperJson } from '@openpanel/json'; @@ -21,12 +21,22 @@ export default function useWS( const ws = context.apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws'); const [baseUrl, setBaseUrl] = useState(`${ws}${path}`); + // Always call the latest onMessage. The memoized (debounced) wrapper below + // otherwise captures the first render's callback — if the path changes + // without unmounting (e.g. switching projects), the socket would reconnect + // but keep invoking a handler closed over the old path's state. + const onMessageRef = useRef(onMessage); + useEffect(() => { + onMessageRef.current = onMessage; + }); + const debouncedOnMessage = useMemo(() => { + const invokeLatest = (event: T) => onMessageRef.current(event); if (options?.debounce) { - return debounce(onMessage, options.debounce.delay, options.debounce); + return debounce(invokeLatest, options.debounce.delay, options.debounce); } - return onMessage; - }, [options?.debounce?.delay]); + return invokeLatest; + }, [options?.debounce?.delay, options?.debounce?.maxWait]); useEffect(() => { if (baseUrl === `${ws}${path}`) return;