diff --git a/.claude/settings.json b/.claude/settings.json
index 48be3f5d..b2f3e1c7 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,8 +1,6 @@
{
"permissions": {
- "allow": [
- "*"
- ]
+ "allow": []
},
"hooks": {
"PostToolUse": [
diff --git a/apps/web/__tests__/api/activation-analytics.test.ts b/apps/web/__tests__/api/activation-analytics.test.ts
new file mode 100644
index 00000000..63442712
--- /dev/null
+++ b/apps/web/__tests__/api/activation-analytics.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/__tests__/api/profile.test.ts b/apps/web/__tests__/api/profile.test.ts
index 2265af6e..c4a3f6f5 100644
--- a/apps/web/__tests__/api/profile.test.ts
+++ b/apps/web/__tests__/api/profile.test.ts
@@ -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",
@@ -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";
@@ -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 = {
+ 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 = {
+ 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 = {
auth: {
diff --git a/apps/web/__tests__/api/usage-status.test.ts b/apps/web/__tests__/api/usage-status.test.ts
new file mode 100644
index 00000000..b72c94ed
--- /dev/null
+++ b/apps/web/__tests__/api/usage-status.test.ts
@@ -0,0 +1,129 @@
+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),
+}));
+
+import { GET } from "@/app/api/usage/status/route";
+import { captureServerActivationEvent } from "@/lib/analytics/server";
+import { createClient } from "@/lib/supabase/server";
+
+function mockUsageStatus({
+ latestUsage,
+ totals = { total_cost: 0, total_tokens: 0 },
+ latestPost = null,
+ latestUsageError = null,
+}: {
+ latestUsage: unknown;
+ totals?: unknown;
+ latestPost?: unknown;
+ latestUsageError?: unknown;
+}) {
+ const latestUsageChain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ order: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({
+ data: latestUsage,
+ error: latestUsageError,
+ }),
+ };
+ const latestPostChain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({
+ data: latestPost,
+ error: null,
+ }),
+ };
+ const rpcChain = {
+ single: vi.fn().mockResolvedValue({
+ data: totals,
+ error: null,
+ }),
+ };
+ vi.mocked(createClient).mockResolvedValue({
+ auth: {
+ getUser: vi.fn().mockResolvedValue({
+ data: { user: { id: "user-1" } },
+ }),
+ },
+ from: vi.fn((table: string) => {
+ if (table === "daily_usage") return latestUsageChain;
+ if (table === "posts") return latestPostChain;
+ throw new Error(`Unexpected table ${table}`);
+ }),
+ rpc: vi.fn(() => rpcChain),
+ } as any);
+
+ return { latestUsageChain, latestPostChain, rpcChain };
+}
+
+describe("GET /api/usage/status", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("does not activate users without usage", async () => {
+ const { latestUsageChain } = mockUsageStatus({ latestUsage: null });
+
+ const res = await GET();
+ const json = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(json).toEqual({ has_data: false, has_usage: false });
+ expect(latestUsageChain.limit).toHaveBeenCalledWith(1);
+ expect(captureServerActivationEvent).not.toHaveBeenCalled();
+ });
+
+ it("captures first sync confirmation when web observes usage", async () => {
+ mockUsageStatus({
+ latestUsage: {
+ id: "usage-1",
+ date: "2026-07-02",
+ created_at: "2026-07-02T10:00:00.000Z",
+ cost_usd: 1.25,
+ total_tokens: 2500,
+ output_tokens: 1200,
+ session_count: 2,
+ models: ["claude-sonnet-4-5-20250929"],
+ },
+ totals: {
+ total_cost: 7.75,
+ total_tokens: 9000,
+ },
+ latestPost: { id: "post-1" },
+ });
+
+ const res = await GET();
+ const json = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(json.has_data).toBe(true);
+ expect(json.has_usage).toBe(true);
+ expect(json.cost_usd).toBe(7.75);
+ expect(json.total_tokens).toBe(9000);
+ expect(json.session_count).toBe(2);
+ expect(json.latest_usage_id).toBe("usage-1");
+ expect(json.latest_usage_at).toBe("2026-07-02T10:00:00.000Z");
+ expect(json.latest_post_url).toBe("/post/post-1");
+ expect(captureServerActivationEvent).toHaveBeenCalledWith({
+ event: "first_sync_confirmed",
+ distinctId: "user-1",
+ properties: expect.objectContaining({
+ surface: "usage_status",
+ activation_state: "activated",
+ is_authenticated: true,
+ session_count: 2,
+ total_tokens: 9000,
+ total_cost_usd: 7.75,
+ "$insert_id": "first_sync_confirmed:user-1:usage-1",
+ }),
+ });
+ });
+});
diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts
index 2cf9e184..1d529462 100644
--- a/apps/web/__tests__/api/usage-submit.test.ts
+++ b/apps/web/__tests__/api/usage-submit.test.ts
@@ -13,11 +13,16 @@ vi.mock("@/lib/supabase/service", () => ({
getServiceClient: vi.fn(),
}));
+vi.mock("@/lib/analytics/server", () => ({
+ captureServerActivationEvent: vi.fn().mockResolvedValue(true),
+}));
+
vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(),
}));
import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route";
+import { captureServerActivationEvent } from "@/lib/analytics/server";
import { createClient } from "@/lib/supabase/server";
import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth";
import { getServiceClient } from "@/lib/supabase/service";
@@ -214,6 +219,18 @@ describe("POST /api/usage/submit", () => {
expect(json.results[0].post_id).toBe("post-1");
expect(json.results[0].post_url).toBe("https://straude.com/post/post-1");
expect(svc.rpc).toHaveBeenCalledWith("recalculate_user_level", { p_user_id: "user-1" });
+ expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({
+ event: "usage_submit_succeeded",
+ distinctId: "user-1",
+ properties: expect.objectContaining({
+ surface: "usage_submit",
+ activation_state: "first_usage_submitted",
+ is_authenticated: true,
+ days_pushed: 1,
+ result_count: 1,
+ total_tokens: 1500,
+ }),
+ }));
});
it("submits multiple days (batch)", async () => {
@@ -383,7 +400,7 @@ describe("POST /api/usage/submit", () => {
expect(json.error).toContain("Unsupported ccusage agents");
});
- it("rejects non-online ccusage pricing metadata", async () => {
+ it("rejects unsupported ccusage pricing metadata", async () => {
(verifyCliToken as any).mockReturnValue("user-1");
mockServiceClient();
@@ -1582,7 +1599,7 @@ describe("POST /api/usage/submit", () => {
claude: "ccusage-claude-v20",
ccusage_version: "20.0.6",
ccusage_agents: ["claude"],
- pricing_mode: "online",
+ pricing_mode: "offline",
},
})
);
@@ -1591,7 +1608,7 @@ describe("POST /api/usage/submit", () => {
claude: "ccusage-claude-v20",
ccusage_version: "20.0.6",
ccusage_agents: ["claude"],
- pricing_mode: "online",
+ pricing_mode: "offline",
};
expect(res.status).toBe(200);
expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual(expectedMeta);
diff --git a/apps/web/__tests__/components/CommandPalette.test.tsx b/apps/web/__tests__/components/CommandPalette.test.tsx
index 0f0e3620..9eeed6f9 100644
--- a/apps/web/__tests__/components/CommandPalette.test.tsx
+++ b/apps/web/__tests__/components/CommandPalette.test.tsx
@@ -1,4 +1,4 @@
-import { act, render, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
@@ -40,6 +40,7 @@ vi.mock("kbar", () => ({
}) => {children}
,
KBarSearch: ({ className }: { className?: string }) => ,
KBarResults: () => null,
+ useKBar: () => ({ query: { toggle: vi.fn() } }),
useMatches: () => ({ results: [] }),
}));
@@ -92,9 +93,13 @@ describe("CommandPalette", () => {
,
);
- expect(capturedActions.map((action) => action.id)).toEqual(
- expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]),
- );
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
+
+ await waitFor(() => {
+ expect(capturedActions.map((action) => action.id)).toEqual(
+ expect.arrayContaining(["theme-light", "theme-dark", "theme-system"]),
+ );
+ });
const darkAction = capturedActions.find((action) => action.id === "theme-dark");
act(() => {
diff --git a/apps/web/__tests__/components/FeedList.test.tsx b/apps/web/__tests__/components/FeedList.test.tsx
index 0cca75cb..7cd7b6c3 100644
--- a/apps/web/__tests__/components/FeedList.test.tsx
+++ b/apps/web/__tests__/components/FeedList.test.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FeedList } from "@/components/app/feed/FeedList";
import type { Post } from "@/types";
@@ -15,6 +15,12 @@ vi.mock("@/components/app/feed/PendingPostsNudge", () => ({
),
}));
+vi.mock("@/lib/analytics/client", () => ({
+ trackActivationEvent: vi.fn(),
+}));
+
+import { trackActivationEvent } from "@/lib/analytics/client";
+
let intersectionCallback:
| ((entries: Array<{ isIntersecting: boolean }>) => void)
| null = null;
@@ -68,6 +74,12 @@ function makePost(id: string, overrides: Partial = {}): Post {
describe("FeedList", () => {
beforeEach(() => {
intersectionCallback = null;
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
vi.stubGlobal(
"fetch",
@@ -157,4 +169,53 @@ describe("FeedList", () => {
);
expect(request.searchParams.get("limit")).toBe("20");
});
+
+ it("shows a copyable first-sync command for the signed-in empty sessions feed", async () => {
+ render(
+ ,
+ );
+
+ const emptyState = screen.getByRole("region", { name: /sync your first session/i });
+ expect(within(emptyState).getByText("Sync your first session")).toBeInTheDocument();
+ expect(within(emptyState).getByText("npx straude@latest")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /copy first sync command/i }));
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith("npx straude@latest");
+ });
+ expect(trackActivationEvent).toHaveBeenCalledWith("sync_command_copied", expect.objectContaining({
+ surface: "empty_state",
+ cta_location: "feed_empty_state",
+ command: "npx straude@latest",
+ }));
+ });
+
+ it("shows a contextual signup CTA after guest feed content", () => {
+ render(
+ ,
+ );
+
+ const cta = screen.getByRole("link", { name: /start your streak/i });
+ expect(cta).toHaveAttribute("href", "/signup");
+
+ cta.addEventListener("click", (event) => event.preventDefault());
+ fireEvent.click(cta);
+
+ expect(trackActivationEvent).toHaveBeenCalledWith("guest_signup_cta_clicked", expect.objectContaining({
+ surface: "feed",
+ cta_location: "feed_after_posts",
+ destination: "/signup",
+ }));
+ });
});
diff --git a/apps/web/__tests__/components/SubmitPromptWidget.test.tsx b/apps/web/__tests__/components/SubmitPromptWidget.test.tsx
index ceaf4649..6b4b1bc8 100644
--- a/apps/web/__tests__/components/SubmitPromptWidget.test.tsx
+++ b/apps/web/__tests__/components/SubmitPromptWidget.test.tsx
@@ -7,6 +7,11 @@ describe("SubmitPromptWidget", () => {
vi.restoreAllMocks();
});
+ async function openPromptModal() {
+ fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
+ return screen.findByRole("dialog");
+ }
+
it("opens the modal and submits a prompt", async () => {
const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
@@ -15,8 +20,7 @@ describe("SubmitPromptWidget", () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
- expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(await openPromptModal()).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Prompt"), {
target: { value: "Please add a compact mode for activity cards in the feed." },
@@ -52,7 +56,7 @@ describe("SubmitPromptWidget", () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
+ await openPromptModal();
fireEvent.change(screen.getByLabelText("Prompt"), {
target: { value: "Please add markdown shortcuts in comments." },
@@ -74,7 +78,7 @@ describe("SubmitPromptWidget", () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
+ await openPromptModal();
fireEvent.click(screen.getByRole("button", { name: /submit as anonymous/i }));
fireEvent.change(screen.getByLabelText("Prompt"), {
@@ -96,10 +100,10 @@ describe("SubmitPromptWidget", () => {
});
});
- it("shows the submit keyboard shortcut hint", () => {
+ it("shows the submit keyboard shortcut hint", async () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
+ await openPromptModal();
expect(
screen.getByRole("button", { name: /submit prompt ⌘↵/i }),
@@ -131,7 +135,7 @@ describe("SubmitPromptWidget", () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: /submit a prompt/i }));
+ await openPromptModal();
fireEvent.click(screen.getByRole("button", { name: /view community prompts/i }));
await waitFor(() => {
diff --git a/apps/web/__tests__/components/TopHeader.test.tsx b/apps/web/__tests__/components/TopHeader.test.tsx
index 117f63e3..179a8d18 100644
--- a/apps/web/__tests__/components/TopHeader.test.tsx
+++ b/apps/web/__tests__/components/TopHeader.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TopHeader } from "@/components/app/shared/TopHeader";
@@ -82,7 +82,7 @@ describe("TopHeader", () => {
});
});
- it("loads shared app counts without fetching message threads", async () => {
+ it("loads shared app counts without fetching notification lists or message threads", async () => {
renderTopHeader({ username: "alice", avatarUrl: null });
await waitFor(() => {
@@ -93,7 +93,21 @@ describe("TopHeader", () => {
String(input),
);
- expect(requestedUrls).toContain("/api/notifications");
+ expect(requestedUrls).not.toContain("/api/notifications");
expect(requestedUrls).not.toContain("/api/messages/threads?limit=1");
});
+
+ it("fetches notifications when the notifications menu opens", async () => {
+ renderTopHeader({ username: "alice", avatarUrl: null });
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("/api/app/counts");
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("/api/notifications");
+ });
+ });
});
diff --git a/apps/web/__tests__/flows/activation-contract.test.ts b/apps/web/__tests__/flows/activation-contract.test.ts
new file mode 100644
index 00000000..887bba8a
--- /dev/null
+++ b/apps/web/__tests__/flows/activation-contract.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ ACTIVATION_EVENTS,
+ deriveActivationState,
+ sanitizeActivationProperties,
+} from "@/lib/analytics/activation";
+
+describe("activation funnel contract", () => {
+ it("keeps the canonical event names stable", () => {
+ expect(ACTIVATION_EVENTS).toEqual([
+ "landing_primary_cta_clicked",
+ "guest_signup_cta_clicked",
+ "signup_started",
+ "signup_completed",
+ "onboarding_profile_started",
+ "sync_command_copied",
+ "first_sync_nudge_clicked",
+ "usage_submit_succeeded",
+ "first_sync_confirmed",
+ "activation_completed",
+ ]);
+ });
+
+ it("treats unauthenticated visitors as anonymous", () => {
+ expect(deriveActivationState({ isAuthenticated: false })).toBe("anonymous");
+ });
+
+ it("does not treat signup or profile setup as activation", () => {
+ expect(deriveActivationState({ isAuthenticated: true })).toBe("signed_up");
+ expect(deriveActivationState({
+ isAuthenticated: true,
+ profileStarted: true,
+ })).toBe("profile_started");
+ });
+
+ it("does not treat command copy or usage submit as activation", () => {
+ expect(deriveActivationState({
+ isAuthenticated: true,
+ syncCommandCopied: true,
+ })).toBe("sync_command_copied");
+ expect(deriveActivationState({
+ isAuthenticated: true,
+ usageSubmitted: true,
+ })).toBe("first_usage_submitted");
+ });
+
+ it("defines activated as first sync confirmed in web", () => {
+ expect(deriveActivationState({
+ isAuthenticated: true,
+ profileStarted: true,
+ syncCommandCopied: true,
+ usageSubmitted: true,
+ webSyncConfirmed: true,
+ })).toBe("activated");
+ });
+
+ it("strips private and high-cardinality fields from activation properties", () => {
+ expect(sanitizeActivationProperties({
+ surface: "onboarding",
+ activation_state: "activated",
+ is_authenticated: true,
+ total_tokens: 1234,
+ ccusage_agents: ["claude", "codex"],
+ email: "user@example.com",
+ prompt: "secret prompt",
+ path: "/Users/someone/project",
+ raw_usage_rows: [{ date: "2026-01-01" }],
+ })).toEqual({
+ surface: "onboarding",
+ activation_state: "activated",
+ is_authenticated: true,
+ total_tokens: 1234,
+ ccusage_agents: ["claude", "codex"],
+ });
+ });
+});
diff --git a/apps/web/__tests__/lib/analytics-server.test.ts b/apps/web/__tests__/lib/analytics-server.test.ts
new file mode 100644
index 00000000..b22714a4
--- /dev/null
+++ b/apps/web/__tests__/lib/analytics-server.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { captureServerActivationEvent } from "@/lib/analytics/server";
+
+describe("server PostHog activation capture", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it("posts sanitized activation events to PostHog capture", async () => {
+ vi.stubEnv("NEXT_PUBLIC_POSTHOG_KEY", "phc_test");
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const result = await captureServerActivationEvent({
+ event: "activation_completed",
+ distinctId: "user-1",
+ properties: {
+ surface: "onboarding",
+ activation_state: "activated",
+ is_authenticated: true,
+ total_tokens: 5000,
+ prompt: "do not send",
+ } as any,
+ });
+
+ expect(result).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://us.i.posthog.com/capture/",
+ expect.objectContaining({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body).toMatchObject({
+ api_key: "phc_test",
+ event: "activation_completed",
+ distinct_id: "user-1",
+ properties: {
+ surface: "onboarding",
+ activation_state: "activated",
+ is_authenticated: true,
+ total_tokens: 5000,
+ },
+ });
+ expect(body.properties.prompt).toBeUndefined();
+ });
+
+ it("skips capture when no PostHog project key is configured", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(captureServerActivationEvent({
+ event: "signup_completed",
+ distinctId: "user-1",
+ })).resolves.toBe(false);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/__tests__/lib/open-stats.test.ts b/apps/web/__tests__/lib/open-stats.test.ts
index 0cb74040..35d66555 100644
--- a/apps/web/__tests__/lib/open-stats.test.ts
+++ b/apps/web/__tests__/lib/open-stats.test.ts
@@ -3,6 +3,7 @@ import {
type OpenStats,
type OpenStatsDb,
getOpenStatsForPage,
+ refreshOpenStatsSnapshot,
} from "@/lib/open-stats";
type FixtureOptions = {
@@ -116,6 +117,61 @@ afterEach(() => {
});
describe("getOpenStatsForPage", () => {
+ it("returns the latest snapshot without running live aggregation", async () => {
+ const snapshotStats = makeSnapshotStats({
+ totalSpend: 101_001,
+ snapshotDate: "2026-03-31",
+ fetchedAt: "2026-03-31T23:59:59.000Z",
+ });
+ const { db, snapshotUpsert } = makeDb({
+ snapshotRows: [
+ {
+ snapshot_date: "2026-03-31",
+ captured_at: "2026-03-31T23:59:59.000Z",
+ stats: snapshotStats,
+ },
+ ],
+ });
+
+ const stats = await getOpenStatsForPage(db);
+
+ expect(stats.source).toBe("snapshot");
+ expect(stats.totalSpend).toBe(101_001);
+ expect(stats.snapshotDate).toBe("2026-03-31");
+ expect(db.from).not.toHaveBeenCalledWith("daily_usage");
+ expect(db.rpc).not.toHaveBeenCalled();
+ expect(snapshotUpsert).not.toHaveBeenCalled();
+ });
+
+ it("returns placeholder stats when the snapshot read fails", async () => {
+ const { db } = makeDb({
+ snapshotError: { message: "connection refused" },
+ });
+
+ const stats = await getOpenStatsForPage(db);
+
+ expect(stats.source).toBe("snapshot");
+ expect(stats.totalSpend).toBe(0);
+ expect(stats.trackedUsers).toBe(0);
+ expect(stats.models).toEqual([]);
+ expect(db.from).not.toHaveBeenCalledWith("daily_usage");
+ expect(db.rpc).not.toHaveBeenCalled();
+ });
+
+ it("returns placeholder stats when no snapshot exists", async () => {
+ const { db } = makeDb({ snapshotRows: [] });
+
+ const stats = await getOpenStatsForPage(db);
+
+ expect(stats.source).toBe("snapshot");
+ expect(stats.totalSpend).toBe(0);
+ expect(stats.trackedUsers).toBe(0);
+ expect(db.from).not.toHaveBeenCalledWith("daily_usage");
+ expect(db.rpc).not.toHaveBeenCalled();
+ });
+});
+
+describe("refreshOpenStatsSnapshot", () => {
it("returns live stats and writes the latest snapshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z"));
@@ -143,7 +199,7 @@ describe("getOpenStatsForPage", () => {
spendRows: [{ date: "2026-04-01", daily_total: 12.5, cumulative_total: 12.5 }],
});
- const stats = await getOpenStatsForPage(db);
+ const stats = await refreshOpenStatsSnapshot(db);
expect(stats.source).toBe("live");
expect(stats.totalSpend).toBe(12.5);
@@ -159,64 +215,25 @@ describe("getOpenStatsForPage", () => {
);
});
- it("falls back to the latest snapshot when the live query fails", async () => {
- const snapshotStats = makeSnapshotStats();
- const { db, snapshotUpsert } = makeDb({
- usageError: { message: "database offline" },
- snapshotRows: [
- {
- snapshot_date: "2026-04-01",
- captured_at: "2026-04-01T12:00:00.000Z",
- stats: snapshotStats,
- },
- ],
- });
-
- const stats = await getOpenStatsForPage(db);
-
- expect(stats.source).toBe("snapshot");
- expect(stats.totalSpend).toBe(snapshotStats.totalSpend);
- expect(stats.fetchedAt).toBe(snapshotStats.fetchedAt);
- expect(snapshotUpsert).not.toHaveBeenCalled();
- });
-
- it("returns placeholder stats when both live and snapshot fail", async () => {
+ it("throws when the live query fails", async () => {
const { db } = makeDb({
- usageError: { message: "connection refused" },
- snapshotError: { message: "connection refused" },
+ usageError: { message: "database offline" },
});
- const stats = await getOpenStatsForPage(db);
-
- expect(stats.source).toBe("snapshot");
- expect(stats.totalSpend).toBe(0);
- expect(stats.trackedUsers).toBe(0);
- expect(stats.models).toEqual([]);
+ await expect(refreshOpenStatsSnapshot(db)).rejects.toThrow(
+ "open stats daily_usage query failed",
+ );
});
- it("falls back to the latest snapshot when live stats come back empty", async () => {
- const snapshotStats = makeSnapshotStats({
- totalSpend: 101_001,
- snapshotDate: "2026-03-31",
- fetchedAt: "2026-03-31T23:59:59.000Z",
- });
+ it("throws when live stats come back empty", async () => {
const { db } = makeDb({
usageRows: [],
concentrationRows: [],
growthRows: [],
- snapshotRows: [
- {
- snapshot_date: "2026-03-31",
- captured_at: "2026-03-31T23:59:59.000Z",
- stats: snapshotStats,
- },
- ],
});
- const stats = await getOpenStatsForPage(db);
-
- expect(stats.source).toBe("snapshot");
- expect(stats.totalSpend).toBe(101_001);
- expect(stats.snapshotDate).toBe("2026-03-31");
+ await expect(refreshOpenStatsSnapshot(db)).rejects.toThrow(
+ "open stats daily_usage query returned no rows",
+ );
});
});
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
index a60b358a..9c16b852 100644
--- a/apps/web/app/(app)/layout.tsx
+++ b/apps/web/app/(app)/layout.tsx
@@ -4,14 +4,16 @@ import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
import { getAuthUser } from "@/lib/supabase/auth";
import { Sidebar } from "@/components/app/shared/Sidebar";
-import { RightSidebar } from "@/components/app/shared/RightSidebar";
+import { LazyRightSidebar } from "@/components/app/shared/RightSidebar";
import { InviteButton } from "@/components/app/profile/InviteButton";
import { ResponsiveShellFrame } from "@/components/app/shared/ResponsiveShellFrame";
import { GuestHeader, GuestMobileNav } from "@/components/app/shared/GuestHeader";
import { CommandPalette } from "@/components/app/shared/CommandPalette";
import { Avatar } from "@/components/ui/Avatar";
import { Skeleton } from "@/components/ui/Skeleton";
+import { AppProviders } from "@/components/providers/AppProviders";
import { firstRelation } from "@/lib/utils/first-relation";
+import { loadUsageTotals } from "@/lib/data/usage-totals";
import type { DailyUsage } from "@/types";
type ShellProfile = {
@@ -31,66 +33,8 @@ type LatestPostRow = {
daily_usage: Array> | null;
};
-type UsageFallbackRow = Pick;
-type UsageTotalsRpcRow = {
- total_cost: number | string | null;
- total_tokens: number | string | null;
-};
type SupabaseServerClient = Awaited>;
-async function loadUsageTotals(
- supabase: SupabaseServerClient,
- userId: string,
-): Promise<{ totalTokens: number; totalCost: number }> {
- const usageTotalsRes = await supabase
- .rpc("get_user_usage_totals", { p_user_id: userId })
- .single();
-
- const loadFallbackUsageTotals = async (): Promise<{ totalTokens: number; totalCost: number }> => {
- const { data: fallbackRows, error: fallbackError } = await supabase
- .from("daily_usage")
- .select("cost_usd, output_tokens")
- .eq("user_id", userId);
-
- if (fallbackError) {
- throw new Error(`Unable to load usage totals from daily_usage fallback (${fallbackError.message})`);
- }
-
- const rows = (fallbackRows ?? []) as UsageFallbackRow[];
- return {
- totalTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0),
- totalCost: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0),
- };
- };
-
- if (usageTotalsRes.error) {
- console.error("get_user_usage_totals RPC failed; using direct daily_usage fallback", {
- userId,
- code: usageTotalsRes.error.code,
- message: usageTotalsRes.error.message,
- });
-
- return loadFallbackUsageTotals();
- }
-
- const usageTotals = usageTotalsRes.data as UsageTotalsRpcRow | null;
- const rpcTokens = usageTotals?.total_tokens;
-
- if (rpcTokens === null || rpcTokens === undefined) {
- console.error("get_user_usage_totals returned no total_tokens; using direct daily_usage fallback", {
- userId,
- rpcKeys: usageTotals ? Object.keys(usageTotals) : [],
- });
-
- return loadFallbackUsageTotals();
- }
-
- return {
- totalTokens: Number(rpcTokens),
- totalCost: Number(usageTotals?.total_cost ?? 0),
- };
-}
-
function formatLatestPosts(rows: LatestPostRow[]) {
return rows
.map((row) => {
@@ -179,23 +123,6 @@ function SidebarFallback({ profile }: { profile: ShellProfile | null }) {
);
}
-function RightSidebarFallback() {
- return (
-
- {[0, 1, 2].map((section) => (
-
- ))}
-
- );
-}
-
async function DeferredSidebar({
userId,
profile,
@@ -252,22 +179,6 @@ async function DeferredSidebar({
);
}
-async function DeferredRightSidebar({
- userId,
-}: {
- userId: string;
-}) {
- const supabase = await createClient();
- const usageTotals = await loadUsageTotals(supabase, userId);
-
- return (
-
- );
-}
-
async function PhotoNudge({
userId,
onboardingIncomplete,
@@ -311,8 +222,9 @@ export default async function AppLayout({
// This check runs server-side as a safety net alongside proxy.ts
// Public pages render with a guest layout below
return (
-
-
+
+
+
@@ -321,7 +233,8 @@ export default async function AppLayout({
-
+
+
);
}
@@ -341,39 +254,38 @@ export default async function AppLayout({
);
- const rightPanel = (
-
}>
-
-
- );
+ const rightPanel =
;
return (
-
-
- {onboardingIncomplete && (
-
- Finish setting up your profile
-
- Complete onboarding
-
-
- )}
-
-
-
-
-
- {children}
-
-
-
+
+
+
+ {onboardingIncomplete && (
+
+ Finish setting up your profile
+
+ Complete onboarding
+
+
+ )}
+
+
+
+
+
+ {children}
+
+
+
+
);
}
diff --git a/apps/web/app/(app)/post/new/CopyCommand.tsx b/apps/web/app/(app)/post/new/CopyCommand.tsx
index 1c99f6c8..0b203486 100644
--- a/apps/web/app/(app)/post/new/CopyCommand.tsx
+++ b/apps/web/app/(app)/post/new/CopyCommand.tsx
@@ -1,50 +1,26 @@
"use client";
-import { useCallback, useState } from "react";
+import { Check, Copy } from "lucide-react";
+import { useClipboardFeedback } from "@/lib/utils/useClipboardFeedback";
export function CopyCommand({ command }: { command: string }) {
- const [copied, setCopied] = useState(false);
-
- const copy = useCallback(() => {
- navigator.clipboard.writeText(command).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- }, [command]);
+ const { copied, copyText } = useClipboardFeedback();
return (
void copyText(command)}
className="group/copy inline-flex items-center gap-2 rounded border border-border bg-subtle px-4 py-2 font-[family-name:var(--font-mono)] text-sm transition-[border-color,background-color] duration-150 hover:border-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{command}
{copied ? (
-
-
-
+
) : (
-
-
-
-
+ />
)}
);
diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx
index 52119c25..f8339b4a 100644
--- a/apps/web/app/(app)/u/[username]/page.tsx
+++ b/apps/web/app/(app)/u/[username]/page.tsx
@@ -16,6 +16,8 @@ import { FeedList } from "@/components/app/feed/FeedList";
import { FollowButton } from "@/components/app/profile/FollowButton";
import { InviteButton } from "@/components/app/profile/InviteButton";
import { CrewPopover, type CrewMember } from "@/components/app/profile/CrewPopover";
+import { FirstSyncCommandCard } from "@/components/app/activation/FirstSyncCommandCard";
+import { GuestSignupCta } from "@/components/app/activation/GuestSignupCta";
import { formatCurrency, formatTokens } from "@/lib/utils/format";
import { enrichFeedPosts, getFeedCursor } from "@/lib/feed-enrichment";
import { firstRelation } from "@/lib/utils/first-relation";
@@ -185,6 +187,7 @@ export default async function ProfilePage({
const totalSpend = totalSpendRows?.reduce((s, r) => s + Number(r.cost_usd), 0) ?? 0;
const lifetimeOutputTokens = totalSpendRows?.reduce((s, r) => s + Number(r.output_tokens), 0) ?? 0;
+ const hasUsage = (totalSpendRows?.length ?? 0) > 0;
const postDateSet = new Set(
((postDates ?? []) as PostDateRow[])
@@ -326,48 +329,61 @@ export default async function ProfilePage({
- {/* Stats row */}
-
-
-
Streak
-
-
- {streak ?? 0} days
-
-
-
-
Output Tokens
-
-
- {formatTokens(lifetimeOutputTokens)}
-
-
-
-
Total Spend
-
- ${formatCurrency(totalSpend)}
-
+ {isOwn && !hasUsage ? (
+
+
- {(crewMembers ?? []).length > 0 && (
-
-
+ ) : (
+ <>
+ {/* Stats row */}
+
+
+
Streak
+
+
+ {streak ?? 0} days
+
+
+
+
Output Tokens
+
+
+ {formatTokens(lifetimeOutputTokens)}
+
+
+
+
Total Spend
+
+ ${formatCurrency(totalSpend)}
+
+
+ {(crewMembers ?? []).length > 0 && (
+
+
+
+ )}
- )}
-
- {/* Achievement badges */}
- {(achievements && achievements.length > 0 || isOwn) && (
-
+ {/* Achievement badges */}
+ {(achievements && achievements.length > 0 || isOwn) && (
+
+ )}
+ >
)}
+ {!authUserId && hasUsage && (
+
+ )}
+
{/* Contribution graph */}
+ {hasUsage && (
Contributions
@@ -389,6 +405,7 @@ export default async function ProfilePage({
)}
+ )}
{/* Posts */}
@@ -408,7 +425,9 @@ export default async function ProfilePage({
/>
) : (
- No activities yet.
+ {isOwn && !hasUsage
+ ? "Your first synced session will appear here."
+ : "No activities yet."}
)}
diff --git a/apps/web/app/(auth)/callback/route.ts b/apps/web/app/(auth)/callback/route.ts
index f40056f4..01886726 100644
--- a/apps/web/app/(auth)/callback/route.ts
+++ b/apps/web/app/(auth)/callback/route.ts
@@ -1,7 +1,20 @@
import { NextResponse } from "next/server";
+import { after } from "@/lib/utils/after";
+import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState } from "@/lib/analytics/activation";
+import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server";
import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
+function getCookieValue(cookieHeader: string | null, name: string): string | null {
+ if (!cookieHeader) return null;
+ const target = `${name}=`;
+ const entry = cookieHeader
+ .split(";")
+ .map((part) => part.trim())
+ .find((part) => part.startsWith(target));
+ return entry ? decodeURIComponent(entry.slice(target.length)) : null;
+}
+
export async function GET(request: Request) {
const { searchParams, origin: requestOrigin } = new URL(request.url);
const origin = requestOrigin;
@@ -26,6 +39,35 @@ export async function GET(request: Request) {
.select("username, github_username, onboarding_completed")
.eq("id", user.id)
.single();
+ const anonymousId = getCookieValue(
+ request.headers.get("cookie"),
+ ACTIVATION_ANONYMOUS_COOKIE,
+ );
+
+ after(async () => {
+ if (anonymousId) {
+ await identifyServerActivationUser({
+ distinctId: user.id,
+ anonymousDistinctId: anonymousId,
+ properties: {
+ is_authenticated: true,
+ activation_state: "signed_up",
+ },
+ });
+ }
+
+ if (!profile?.onboarding_completed) {
+ await captureServerActivationEvent({
+ event: "signup_completed",
+ distinctId: user.id,
+ properties: {
+ surface: "auth_callback",
+ activation_state: deriveActivationState({ isAuthenticated: true }),
+ is_authenticated: true,
+ },
+ });
+ }
+ });
if (profile && !profile.username && profile.github_username) {
const sanitized = profile.github_username
diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx
index f0d06e6a..5e870d93 100644
--- a/apps/web/app/(auth)/layout.tsx
+++ b/apps/web/app/(auth)/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { PublicAnalytics } from "@/components/providers/PublicAnalytics";
export const metadata: Metadata = {
title: "Sign in to Straude",
@@ -13,8 +14,11 @@ export default function AuthLayout({
children: React.ReactNode;
}) {
return (
-
- {children}
-
+ <>
+
+ {children}
+
+
+ >
);
}
diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx
index 694e69ae..6d916796 100644
--- a/apps/web/app/(auth)/signup/page.tsx
+++ b/apps/web/app/(auth)/signup/page.tsx
@@ -6,6 +6,7 @@ import Link from "next/link";
import { BoltIcon } from "@/components/landing/icons";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
+import { trackActivationEvent } from "@/lib/analytics/client";
export default function SignupPage() {
const [email, setEmail] = useState("");
@@ -17,6 +18,12 @@ export default function SignupPage() {
e.preventDefault();
setLoading(true);
setError(null);
+ trackActivationEvent("signup_started", {
+ surface: "signup",
+ signup_method: "magic_link",
+ activation_state: "anonymous",
+ is_authenticated: false,
+ });
const supabase = createClient();
const { error } = await supabase.auth.signInWithOtp({
@@ -33,6 +40,12 @@ export default function SignupPage() {
}
async function handleGitHub() {
+ trackActivationEvent("signup_started", {
+ surface: "signup",
+ signup_method: "github",
+ activation_state: "anonymous",
+ is_authenticated: false,
+ });
const supabase = createClient();
await supabase.auth.signInWithOAuth({
provider: "github",
diff --git a/apps/web/app/(landing)/layout.tsx b/apps/web/app/(landing)/layout.tsx
index 26983436..63f55040 100644
--- a/apps/web/app/(landing)/layout.tsx
+++ b/apps/web/app/(landing)/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { CookieConsentModal } from "@/components/landing/CookieConsentModal";
+import { PublicAnalytics } from "@/components/providers/PublicAnalytics";
export const metadata: Metadata = {
title: { absolute: "Straude — Strava for Claude Code" },
@@ -15,6 +16,7 @@ export default function LandingLayout({
return (
<>
{children}
+
>
);
diff --git a/apps/web/app/(landing)/open/page.tsx b/apps/web/app/(landing)/open/page.tsx
index 38cfcda3..fb763da1 100644
--- a/apps/web/app/(landing)/open/page.tsx
+++ b/apps/web/app/(landing)/open/page.tsx
@@ -164,9 +164,7 @@ export default async function OpenStatsPage() {
Daily, anonymized data from the Straude community.
- {stats.source === "snapshot"
- ? "Showing the last successful snapshot while the live refresh recovers."
- : "Updated daily from the latest successful Straude snapshot."}
+ Updated daily from the latest successful Straude snapshot.
{/* ---- Big Number Grid ----------------------------------------- */}
diff --git a/apps/web/app/(onboarding)/layout.tsx b/apps/web/app/(onboarding)/layout.tsx
index e07d636a..73f475be 100644
--- a/apps/web/app/(onboarding)/layout.tsx
+++ b/apps/web/app/(onboarding)/layout.tsx
@@ -1,6 +1,7 @@
import { createClient } from "@/lib/supabase/server";
import { getServiceClient } from "@/lib/supabase/service";
import { redirect } from "next/navigation";
+import { AppProviders } from "@/components/providers/AppProviders";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -35,8 +36,10 @@ export default async function OnboardingLayout({
}
return (
-
+
+
+
);
}
diff --git a/apps/web/app/(onboarding)/onboarding/page.tsx b/apps/web/app/(onboarding)/onboarding/page.tsx
index c02b0549..934d111b 100644
--- a/apps/web/app/(onboarding)/onboarding/page.tsx
+++ b/apps/web/app/(onboarding)/onboarding/page.tsx
@@ -7,18 +7,21 @@ import { BoltIcon } from "@/components/landing/icons";
import { Check, X, Loader2, ArrowRight, Copy } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
-import { Textarea } from "@/components/ui/Textarea";
-import { CountryPicker } from "@/components/ui/CountryPicker";
+import { trackActivationEvent } from "@/lib/analytics/client";
import { formatCurrency } from "@/lib/utils/format";
const SYNC_COMMAND = "npx straude@latest";
interface UsageStatus {
has_data: boolean;
+ has_usage?: boolean;
cost_usd?: number;
total_tokens?: number;
session_count?: number;
top_model?: string | null;
+ latest_usage_id?: string;
+ latest_usage_at?: string | null;
+ latest_post_url?: string | null;
}
function formatTokens(n: number): string {
@@ -32,17 +35,27 @@ function Step3LogSession({ username }: { username: string }) {
const [copied, setCopied] = useState(false);
const [phase, setPhase] = useState<"waiting" | "success">("waiting");
const [data, setData] = useState(null);
+ const [hasExistingUsage, setHasExistingUsage] = useState(false);
+ const [completionError, setCompletionError] = useState(null);
+ const activationTrackedRef = useRef(false);
+ const activationPersistedRef = useRef(false);
+ const commandCopiedRef = useRef(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(SYNC_COMMAND).then(() => {
+ commandCopiedRef.current = true;
+ trackActivationEvent("sync_command_copied", {
+ surface: "onboarding",
+ command: SYNC_COMMAND,
+ activation_state: "sync_command_copied",
+ is_authenticated: true,
+ });
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
- // Poll for NEW session data (ignore pre-existing usage)
const intervalRef = useRef>(undefined);
- const baselineRef = useRef(null);
useEffect(() => {
let active = true;
@@ -52,17 +65,11 @@ function Step3LogSession({ username }: { username: string }) {
const res = await fetch("/api/usage/status");
if (!res.ok) return;
const json: UsageStatus = await res.json();
- const count = json.session_count ?? 0;
+ const hasUsage = json.has_usage ?? json.has_data;
- // First poll: record the baseline
- if (baselineRef.current === null) {
- baselineRef.current = count;
- return;
- }
-
- // Only transition when sessions increase beyond baseline
- if (count > baselineRef.current && active) {
+ if (hasUsage && active) {
setData(json);
+ setHasExistingUsage(!commandCopiedRef.current);
setPhase("success");
if (intervalRef.current) clearInterval(intervalRef.current);
}
@@ -79,6 +86,49 @@ function Step3LogSession({ username }: { username: string }) {
};
}, []);
+ useEffect(() => {
+ if (phase !== "success" || !data || activationPersistedRef.current) return;
+
+ activationPersistedRef.current = true;
+ const observedUsage = data;
+
+ async function persistActivation() {
+ const res = await fetch("/api/users/me", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ onboarding_completed: true }),
+ });
+
+ if (!res.ok) {
+ const payload = await res.json().catch(() => ({}));
+ setCompletionError(
+ typeof payload.error === "string"
+ ? payload.error
+ : "We saw your usage, but setup could not be completed. Refresh and try again.",
+ );
+ return;
+ }
+
+ if (!activationTrackedRef.current) {
+ activationTrackedRef.current = true;
+ trackActivationEvent("activation_completed", {
+ surface: "onboarding",
+ activation_state: "activated",
+ is_authenticated: true,
+ session_count: observedUsage.session_count,
+ total_tokens: observedUsage.total_tokens,
+ total_cost_usd: observedUsage.cost_usd,
+ has_existing_usage: hasExistingUsage,
+ "$insert_id": observedUsage.latest_usage_id
+ ? `activation_completed:${observedUsage.latest_usage_id}`
+ : "activation_completed:onboarding",
+ });
+ }
+ }
+
+ void persistActivation();
+ }, [data, hasExistingUsage, phase]);
+
if (phase === "success" && data) {
return (
<>
@@ -96,7 +146,8 @@ function Step3LogSession({ username }: { username: string }) {
- Your usage is live. Here's what we captured.
+ Your usage is live. Straude can now build your streak, spend totals,
+ and shareable session history.
{/* Stats grid */}
@@ -127,7 +178,23 @@ function Step3LogSession({ username }: { username: string }) {
+ {completionError && (
+
+ {completionError}
+
+ )}
+
+ {data.latest_post_url && (
+
router.push(data.latest_post_url ?? "/feed")}
+ className="py-3"
+ >
+ View session
+
+ )}
router.push(username ? `/u/${username}` : "/feed")}
className="flex-1 py-3"
@@ -157,11 +224,11 @@ function Step3LogSession({ username }: { username: string }) {
className="text-2xl font-medium tracking-tight"
style={{ letterSpacing: "-0.03em" }}
>
- Log your first session
+ Sync your first session
- Run this in your terminal to post today's Claude Code usage to your
- profile. Takes about 10 seconds.
+ Run this in your terminal after a Claude Code or Codex session. Straude
+ will post your usage stats as soon as the web app sees them.
{/* Copy-to-clipboard command */}
@@ -184,7 +251,7 @@ function Step3LogSession({ username }: { username: string }) {
{/* Privacy assurance */}
- Only aggregate stats leave your machine — token counts, cost, model
+ Only aggregate stats leave your machine - token counts, cost, model
names. Your prompts, code, and conversations never do.{" "}
Privacy policy
@@ -203,7 +270,7 @@ function Step3LogSession({ username }: { username: string }) {
variant="secondary"
className="flex-1 py-3"
>
- I'll do this later
+ Explore without syncing
@@ -229,12 +296,6 @@ export default function OnboardingPage() {
const [usernameStatus, setUsernameStatus] = useState("idle");
const debounceRef = useRef>(undefined);
- // Step 2
- const [bio, setBio] = useState("");
- const [heardAbout, setHeardAbout] = useState("");
- const [country, setCountry] = useState("");
- const [githubUsername, setGithubUsername] = useState("");
-
// Auto-detect timezone
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -244,9 +305,6 @@ export default function OnboardingPage() {
const res = await fetch("/api/users/me");
if (res.ok) {
const profile = await res.json();
- if (profile.github_username) {
- setGithubUsername(profile.github_username);
- }
// Pre-fill username: use existing username (e.g. auto-claimed from GitHub),
// otherwise suggest from GitHub handle
if (profile.username) {
@@ -262,9 +320,6 @@ export default function OnboardingPage() {
}
}
if (profile.display_name) setDisplayName(profile.display_name);
- if (profile.country) setCountry(profile.country);
- if (profile.bio) setBio(profile.bio);
- if (profile.heard_about) setHeardAbout(profile.heard_about);
}
}
loadProfile();
@@ -300,20 +355,15 @@ export default function OnboardingPage() {
};
}, [username]);
- async function handleFinish() {
+ async function handleProfileSave() {
setSaving(true);
setError(null);
const body: Record = {
timezone,
- onboarding_completed: true,
};
if (username) body.username = username;
if (displayName) body.display_name = displayName;
- if (bio) body.bio = bio;
- body.heard_about = heardAbout.trim() || null;
- if (country) body.country = country;
- if (githubUsername) body.github_username = githubUsername;
const res = await fetch("/api/users/me", {
method: "PATCH",
@@ -328,6 +378,7 @@ export default function OnboardingPage() {
return;
}
+ setSaving(false);
setStep(3);
}
@@ -414,7 +465,14 @@ export default function OnboardingPage() {
setStep(2)}
+ onClick={() => {
+ trackActivationEvent("onboarding_profile_started", {
+ surface: "onboarding",
+ activation_state: "profile_started",
+ is_authenticated: true,
+ });
+ setStep(2);
+ }}
disabled={!canProceed}
className="flex-1 py-3"
>
@@ -423,12 +481,6 @@ export default function OnboardingPage() {
-
-
- Skip for now
-
-
-
@@ -449,74 +501,25 @@ export default function OnboardingPage() {
className="text-2xl font-medium tracking-tight"
style={{ letterSpacing: "-0.03em" }}
>
- Almost there
+ Ready to sync
- Optional details to round out your profile. You can always change these
- later in Settings.
+ Your first sync unlocks the useful parts of Straude: spend, tokens,
+ streaks, and a shareable session you can edit after it lands.
-
-
-
-
-
- How did you hear about us?
-
-