Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fccf0d9
refactor: simplify message and push presentation
ohong Jun 2, 2026
c5bf572
migrate usage push to bundled ccusage v20
ohong Jun 2, 2026
507e67c
Fix Claude review workflow auth
ohong Jun 11, 2026
a00049c
Add activation improvement workstream plans
ohong Jul 2, 2026
aaaccef
Implement activation analytics contract
ohong Jul 2, 2026
ac162e6
Gate onboarding completion on first sync
ohong Jul 2, 2026
66cb651
Speed up public landing pages
ohong Jul 2, 2026
8d071b3
Defer authenticated shell work
ohong Jul 2, 2026
a14874d
Make CLI first sync faster
ohong Jul 2, 2026
4ead8e6
Clarify first-sync app activation states
ohong Jul 2, 2026
8f03ffe
Merge onboarding first-sync workstream
ohong Jul 2, 2026
3dce2ee
Merge public landing performance workstream
ohong Jul 2, 2026
e816a09
Merge authenticated shell performance workstream
ohong Jul 2, 2026
aa2e584
Merge core app activation UX workstream
ohong Jul 2, 2026
cbe6190
Merge CLI first-run snappiness workstream
ohong Jul 2, 2026
ce48557
Document activation integration rollout
ohong Jul 2, 2026
89f6021
Merge remote-tracking branch 'origin/main' into codex-thermo-quality-…
ohong Jul 3, 2026
463200a
Restrict Claude workspace permissions
ohong Jul 3, 2026
1d2d474
Merge remote-tracking branch 'origin/main' into codex/activation-funn…
ohong Jul 3, 2026
ca656e7
Restore Claude review OAuth workflow
ohong Jul 3, 2026
3a68c85
Merge branch 'codex/activation-funnel-contract' into codex/activation…
ohong Jul 3, 2026
da8f578
Update TopHeader lazy notification test
ohong Jul 3, 2026
d2f9593
Merge thermo quality refactor into activation integration
ohong Jul 3, 2026
3426d31
Make after helper safe for direct route tests
ohong Jul 3, 2026
76e8a46
Merge branch 'codex/activation-funnel-contract' into codex/activation…
ohong Jul 3, 2026
4786c7a
Integrate activation workstreams (#139)
ohong Jul 3, 2026
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
4 changes: 1 addition & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
{
"permissions": {
"allow": [
"*"
]
"allow": []
},
"hooks": {
"PostToolUse": [
Expand Down
112 changes: 112 additions & 0 deletions apps/web/__tests__/api/activation-analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}));

vi.mock("@/lib/analytics/server", () => ({
captureServerActivationEvent: vi.fn().mockResolvedValue(true),
identifyServerActivationUser: vi.fn().mockResolvedValue(true),
}));

import { POST } from "@/app/api/analytics/activation/route";
import { ACTIVATION_ANONYMOUS_COOKIE } from "@/lib/analytics/activation";
import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server";
import { createClient } from "@/lib/supabase/server";

function mockAuthUser(userId: string | null) {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
} as any);
}

function request(body: unknown, cookie?: string) {
return new Request("http://localhost/api/analytics/activation", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
}

describe("POST /api/analytics/activation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthUser(null);
});

it("captures anonymous lifecycle events with a short-lived activation id cookie", async () => {
const res = await POST(request({
event: "signup_started",
properties: {
surface: "signup",
signup_method: "magic_link",
email: "private@example.com",
},
}));

expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toContain(`${ACTIVATION_ANONYMOUS_COOKIE}=`);
expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({
event: "signup_started",
distinctId: expect.any(String),
properties: expect.objectContaining({
surface: "signup",
signup_method: "magic_link",
is_authenticated: false,
activation_state: "anonymous",
}),
}));
expect(captureServerActivationEvent).not.toHaveBeenCalledWith(expect.objectContaining({
properties: expect.objectContaining({ email: expect.anything() }),
}));
});

it("identifies authenticated users against the anonymous activation id", async () => {
mockAuthUser("user-1");

const res = await POST(request(
{
event: "sync_command_copied",
properties: {
surface: "onboarding",
command: "npx straude@latest",
},
},
`${ACTIVATION_ANONYMOUS_COOKIE}=anon-1`,
));

expect(res.status).toBe(200);
expect(identifyServerActivationUser).toHaveBeenCalledWith({
distinctId: "user-1",
anonymousDistinctId: "anon-1",
properties: {
is_authenticated: true,
activation_state: "sync_command_copied",
},
});
expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({
event: "sync_command_copied",
distinctId: "user-1",
properties: expect.objectContaining({
is_authenticated: true,
activation_state: "sync_command_copied",
}),
}));
});

it("rejects events that are not in the client lifecycle allowlist", async () => {
const res = await POST(request({ event: "usage_submit_succeeded" }));
const json = await res.json();

expect(res.status).toBe(400);
expect(json.error).toBe("Invalid activation event");
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});
});
139 changes: 139 additions & 0 deletions apps/web/__tests__/api/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ vi.mock("@/lib/supabase/service", () => ({
getServiceClient: vi.fn(),
}));

vi.mock("@/lib/email/send-welcome-email", () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/referral", () => ({
attributeReferral: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/analytics/server", () => ({
captureServerActivationEvent: vi.fn().mockResolvedValue(true),
}));

vi.mock("@/lib/constants/regions", () => ({
COUNTRY_TO_REGION: {
US: "north_america",
Expand All @@ -18,6 +30,8 @@ vi.mock("@/lib/constants/regions", () => ({

import { GET as getPublicProfile } from "@/app/api/users/[username]/route";
import { GET as getOwnProfile, PATCH } from "@/app/api/users/me/route";
import { captureServerActivationEvent } from "@/lib/analytics/server";
import { sendWelcomeEmail } from "@/lib/email/send-welcome-email";
import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
import { NextRequest } from "next/server";
Expand Down Expand Up @@ -351,6 +365,131 @@ describe("PATCH /api/users/me", () => {
expect(json.username).toBe("new_name");
});

it("does not complete onboarding before first sync is present", async () => {
const authClient: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1", email: "u1@example.com" } },
error: null,
}),
},
};
const updateMock = vi.fn();
const dailyUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: null,
error: null,
}),
};
const db = {
from: vi.fn((table: string) => {
if (table === "daily_usage") return dailyUsageChain;
if (table === "users") return { update: updateMock };
throw new Error(`Unexpected table ${table}`);
}),
};
(createClient as any).mockResolvedValue(authClient);
(getServiceClient as any).mockReturnValue(db);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", { onboarding_completed: true })
);
const json = await res.json();

expect(res.status).toBe(409);
expect(json.error).toBe("Sync your first session before completing onboarding");
expect(updateMock).not.toHaveBeenCalled();
expect(sendWelcomeEmail).not.toHaveBeenCalled();
expect(captureServerActivationEvent).not.toHaveBeenCalled();
});

it("completes onboarding after first sync and captures activation", async () => {
const authClient: Record<string, any> = {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "u-1", email: "u1@example.com" } },
error: null,
}),
},
};
const usageRow = {
id: "usage-1",
session_count: 2,
total_tokens: 2500,
};
const updatedProfile = {
id: "u-1",
username: "alice",
onboarding_completed: true,
};
const dailyUsageChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: usageRow,
error: null,
}),
};
const updateMock = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: updatedProfile,
error: null,
}),
}),
}),
});
const leaderboardChain = {
select: vi.fn().mockReturnThis(),
neq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
};
const db = {
from: vi.fn((table: string) => {
if (table === "daily_usage") return dailyUsageChain;
if (table === "users") return { update: updateMock };
if (table === "leaderboard_weekly") return leaderboardChain;
throw new Error(`Unexpected table ${table}`);
}),
};
(createClient as any).mockResolvedValue(authClient);
(getServiceClient as any).mockReturnValue(db);

const res = await PATCH(
makeRequest("PATCH", "/api/users/me", { onboarding_completed: true })
);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.onboarding_completed).toBe(true);
expect(updateMock).toHaveBeenCalledWith({ onboarding_completed: true });
expect(sendWelcomeEmail).toHaveBeenCalledWith({
userId: "u-1",
email: "u1@example.com",
username: "alice",
});
expect(captureServerActivationEvent).toHaveBeenCalledWith({
event: "activation_completed",
distinctId: "u-1",
properties: expect.objectContaining({
surface: "onboarding",
activation_state: "activated",
is_authenticated: true,
session_count: 2,
total_tokens: 2500,
"$insert_id": "activation_completed:usage-1",
}),
});
});

it("validates username format", async () => {
const client: Record<string, any> = {
auth: {
Expand Down
Loading
Loading