Skip to content

Commit e604dd7

Browse files
committed
Count first_ai_success only for new accounts, let a later deploy reload again, and plainer sign-in copy
1 parent 180f3da commit e604dd7

9 files changed

Lines changed: 82 additions & 24 deletions

File tree

‎docs/architecture.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,9 @@ A run that produces nothing costs nothing. When an `ai` request fails on the ser
170170

171171
Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary.
172172

173-
The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads.
173+
The facade accepts only the event names and property values listed in it. `ai_request_failed` says why a request failed: `tool`, `error_code` (one of `limit_reached`, `model_restricted`, `auth_expired`, `timeout`, `network`, `aborted`, `provider_error`, `invalid_response`, `server_error`, `unknown`, mapped from the client's AI error classes in [`src/lib/ai/failure-reason.ts`](../src/lib/ai/failure-reason.ts)), `http_status`, `stage` (`request`, `stream`, `parse`), `streaming` and `duration_ms`. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in [`src/lib/activation-events.ts`](../src/lib/activation-events.ts) are `signup_completed` (`method`), `trial_started`, `paywall_shown` (`reason`, `tool`), `first_ai_success` (`tool`) and `welcome_completed`. The once-per-account ones remember what they sent in this browser's local storage. `first_ai_success` is sent only for an account made in the last seven days, so an older account's next answer is not counted as its first. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads.
174174

175-
Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path (guarded in `sessionStorage`, shared with the `vite:preloadError` listener) instead of crashing on a module that loaded as nothing.
175+
Every `React.lazy` component and route loader goes through `loadChunk` in [`src/lib/lazy-chunk.ts`](../src/lib/lazy-chunk.ts). After a deploy removes an old file, the page reloads once per path in ten minutes (the time is kept in `sessionStorage` and shared with the `vite:preloadError` listener, so a file still missing after the reload stays an error while a later deploy can reload again) instead of crashing on a module that loaded as nothing.
176176

177177
CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values.
178178

‎src/App.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib
1919
import { OfflineIndicator } from "@/components/OfflineIndicator";
2020
import { StagingBanner } from "@/components/StagingBanner";
2121
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
22-
import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted } from "@/lib/activation-events";
22+
import { captureSignupCompleted, captureTrialStarted, captureWelcomeCompleted, rememberActivationAccount } from "@/lib/activation-events";
2323
import { useSubscription } from "@/hooks/use-subscription";
2424
import { lazyChunk, loadChunk, namedChunk } from "@/lib/lazy-chunk";
2525
import { appLayoutLoader, localizedRouteLoaders, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules";
@@ -273,12 +273,13 @@ export function ToolRouteAnalytics(): null {
273273
return null;
274274
}
275275

276-
/** signup_completed and trial_started, once per new account. AppLayout's trial banner shares this subscription read. */
276+
/** signup_completed and trial_started, once per new account, and the account first_ai_success checks. AppLayout's trial banner shares this subscription read. */
277277
export function ActivationAnalytics(): null {
278278
const { user } = useAuth();
279279
const { tier, loading } = useSubscription();
280280

281281
useEffect(() => {
282+
rememberActivationAccount(user ?? null);
282283
if (user) captureSignupCompleted(user);
283284
}, [user]);
284285

‎src/lib/activation-events.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,31 @@ function signedInUserId(): string | null {
5555
}
5656
}
5757

58-
/** The first answer an account gets from any tool. */
59-
export function captureFirstAISuccess(tool: string): void {
60-
captureOncePerAccount('first_ai_success', signedInUserId(), { tool });
61-
}
62-
6358
export interface ActivationUser {
6459
id: string;
6560
created_at?: string;
6661
app_metadata?: Record<string, unknown>;
6762
}
6863

64+
// A first answer counts only for an account this young. Older accounts had
65+
// answers before this event existed, so their next one is not their first.
66+
const FIRST_SUCCESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
67+
68+
let activationAccount: ActivationUser | null = null;
69+
70+
/** The signed-in account, so captureFirstAISuccess can tell a new account from an old one. */
71+
export function rememberActivationAccount(user: ActivationUser | null): void {
72+
activationAccount = user;
73+
}
74+
75+
/** The first answer a new account gets from any tool. */
76+
export function captureFirstAISuccess(tool: string, now = Date.now()): void {
77+
const accountId = signedInUserId();
78+
if (!accountId || activationAccount?.id !== accountId) return;
79+
if (!isNewAccount(activationAccount, now, FIRST_SUCCESS_WINDOW_MS)) return;
80+
captureOncePerAccount('first_ai_success', accountId, { tool });
81+
}
82+
6983
export type SignupMethod = 'demo' | 'email' | 'google';
7084

7185
export function signupMethod(user: ActivationUser): SignupMethod | null {
@@ -76,9 +90,9 @@ export function signupMethod(user: ActivationUser): SignupMethod | null {
7690
return null;
7791
}
7892

79-
export function isNewAccount(user: ActivationUser, now = Date.now()): boolean {
93+
export function isNewAccount(user: ActivationUser, now = Date.now(), windowMs = NEW_ACCOUNT_WINDOW_MS): boolean {
8094
const created = user.created_at ? Date.parse(user.created_at) : NaN;
81-
return Number.isFinite(created) && now - created >= 0 && now - created <= NEW_ACCOUNT_WINDOW_MS;
95+
return Number.isFinite(created) && now - created >= 0 && now - created <= windowMs;
8296
}
8397

8498
/** A first sign-in to an account created in the last day. Returns whether it was sent. */

‎src/lib/reload-stale-chunk.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
const RELOAD_PREFIX = 'debatelab-stale-chunk:';
2+
// One automatic reload per path in this window. A file still missing after
3+
// the reload stays an error instead of refreshing the tab again, while a
4+
// later deploy in the same tab can still reload once.
5+
export const STALE_CHUNK_RELOAD_WINDOW_MS = 10 * 60 * 1000;
26

37
type ReloadTarget = Pick<Window, 'addEventListener' | 'sessionStorage' | 'location'> & {
48
navigator: Pick<Navigator, 'onLine'>;
@@ -19,17 +23,23 @@ export function resetStaleChunkReloadForTests(): void {
1923
/**
2024
* Reloads the page once per path for a chunk the deployed build no longer has.
2125
* Returns whether a reload was started. A second failure on the same path
22-
* stays put, so a missing file cannot refresh the tab forever. Offline the
26+
* within STALE_CHUNK_RELOAD_WINDOW_MS stays put, so a missing file cannot
27+
* refresh the tab forever. Offline the
2328
* chunk is not stale, only unreachable: a reload could replace the page with
2429
* the browser's offline error, so the error stands instead.
2530
*/
26-
export function reloadOnceForStaleChunk(target: ReloadTarget = window): boolean {
31+
export function reloadOnceForStaleChunk(target: ReloadTarget = window, now: number = Date.now()): boolean {
2732
if (reloadRequested) return true;
2833
if (target.navigator.onLine === false) return false;
2934
const key = `${RELOAD_PREFIX}${target.location.pathname || '/'}`;
3035
try {
31-
if (target.sessionStorage.getItem(key) === '1') return false;
32-
target.sessionStorage.setItem(key, '1');
36+
// The value is when this path last reloaded. A '1' from an older build has no time and counts as old.
37+
const stored = target.sessionStorage.getItem(key);
38+
if (stored !== null && stored !== '1') {
39+
const last = Number(stored);
40+
if (!Number.isFinite(last) || now - last < STALE_CHUNK_RELOAD_WINDOW_MS) return false;
41+
}
42+
target.sessionStorage.setItem(key, String(now));
3343
} catch {
3444
return false;
3545
}

‎src/locales/en.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2690,7 +2690,7 @@
26902690
},
26912691
"serverErrors": {
26922692
"signInRequired": "Please sign in to continue.",
2693-
"sessionExpired": "Authentication failed. Your session may have expired. Please sign in again.",
2693+
"sessionExpired": "We couldn't confirm you're signed in. Sign in again to keep going.",
26942694
"accessDenied": "Access denied. Your session may have expired. Please sign in again.",
26952695
"banned": "Your account has been suspended. Please contact support.",
26962696
"requestTooLarge": "Request is too large. Shorten the input and try again.",

‎src/locales/es.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,7 +2597,7 @@
25972597
"unexpected": "Se produjo un error inesperado",
25982598
"offline": "Comprueba tu conexión e inténtalo de nuevo.",
25992599
"notCharged": "No se te ha cobrado esta solicitud.",
2600-
"signInAgain": "Volver a iniciar sesión"
2600+
"signInAgain": "Vuelve a iniciar sesión"
26012601
},
26022602
"clock": {
26032603
"timeUp": "Se acabó el tiempo.",
@@ -2690,7 +2690,7 @@
26902690
},
26912691
"serverErrors": {
26922692
"signInRequired": "Inicia sesión para continuar.",
2693-
"sessionExpired": "No se pudo verificar tu sesión. Puede que haya caducado. Vuelve a iniciar sesión.",
2693+
"sessionExpired": "No pudimos comprobar que has iniciado sesión. Vuelve a iniciar sesión para continuar.",
26942694
"accessDenied": "Acceso denegado. Puede que tu sesión haya caducado. Vuelve a iniciar sesión.",
26952695
"banned": "Tu cuenta está suspendida. Ponte en contacto con soporte.",
26962696
"requestTooLarge": "La solicitud es demasiado grande. Acorta el texto e inténtalo de nuevo.",

‎src/locales/hu.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,7 +2597,7 @@
25972597
"unexpected": "Váratlan hiba történt",
25982598
"offline": "Ellenőrizd a kapcsolatot, és próbáld újra.",
25992599
"notCharged": "Ezt a kérést nem számoltuk el.",
2600-
"signInAgain": "Bejelentkezés újra"
2600+
"signInAgain": "Jelentkezz be újra"
26012601
},
26022602
"clock": {
26032603
"timeUp": "Lejárt az idő.",
@@ -2690,7 +2690,7 @@
26902690
},
26912691
"serverErrors": {
26922692
"signInRequired": "A folytatáshoz jelentkezz be.",
2693-
"sessionExpired": "A hitelesítés nem sikerült. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.",
2693+
"sessionExpired": "Nem sikerült ellenőrizni, hogy be vagy-e jelentkezve. Jelentkezz be újra a folytatáshoz.",
26942694
"accessDenied": "Hozzáférés megtagadva. Lehet, hogy lejárt a munkameneted. Jelentkezz be újra.",
26952695
"banned": "A fiókodat felfüggesztettük. Fordulj az ügyfélszolgálathoz.",
26962696
"requestTooLarge": "A kérés túl nagy. Rövidítsd le a szöveget, és próbáld újra.",

‎src/test/lib/activation-events.test.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock('@/lib/supabase/client', () => ({ getActiveUserId: () => activeUser.id }
1111
import {
1212
captureFirstAISuccess,
1313
captureOncePerAccount,
14+
rememberActivationAccount,
1415
captureSignupCompleted,
1516
captureTrialStarted,
1617
captureWelcomeCompleted,
@@ -28,21 +29,34 @@ describe('activation events', () => {
2829
localStorage.clear();
2930
captureProductEventMock.mockReset();
3031
activeUser.id = null;
32+
rememberActivationAccount(null);
3133
});
3234

3335
it('sends first_ai_success once per account, and again for another account', () => {
3436
activeUser.id = 'user-a';
35-
captureFirstAISuccess('ask');
36-
captureFirstAISuccess('generate');
37+
rememberActivationAccount(newUser('user-a', { provider: 'email' }));
38+
captureFirstAISuccess('ask', NOW);
39+
captureFirstAISuccess('generate', NOW);
3740
activeUser.id = 'user-b';
38-
captureFirstAISuccess('crossfire');
41+
rememberActivationAccount(newUser('user-b', { provider: 'google' }));
42+
captureFirstAISuccess('crossfire', NOW);
3943

4044
expect(sent('first_ai_success')).toEqual([
4145
['first_ai_success', { tool: 'ask' }],
4246
['first_ai_success', { tool: 'crossfire' }],
4347
]);
4448
});
4549

50+
it('does not count an older account\'s next answer as its first', () => {
51+
activeUser.id = 'old-user';
52+
rememberActivationAccount({ id: 'old-user', created_at: '2026-09-01T00:00:00Z' });
53+
captureFirstAISuccess('ask', NOW);
54+
// An account the page has not read yet is not guessed at either.
55+
activeUser.id = 'unknown-user';
56+
captureFirstAISuccess('ask', NOW);
57+
expect(sent('first_ai_success')).toHaveLength(0);
58+
});
59+
4660
it('sends nothing without an account or when storage cannot remember', () => {
4761
captureFirstAISuccess('ask');
4862
const blocked = { getItem: () => { throw new Error('blocked'); }, setItem: (): void => undefined };

‎src/test/lib/reload-stale-chunk.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { installStaleChunkReload, resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk';
2+
import { STALE_CHUNK_RELOAD_WINDOW_MS, installStaleChunkReload, reloadOnceForStaleChunk, resetStaleChunkReloadForTests } from '@/lib/reload-stale-chunk';
33

44
function fakeWindow() {
55
const listeners = new Map<string, (event: Event) => void>();
@@ -45,6 +45,25 @@ describe('stale chunk reload', () => {
4545
expect(reload).toHaveBeenCalledTimes(1);
4646
});
4747

48+
it('reloads again for a later deploy, but not twice inside the window', () => {
49+
const { target, reload } = fakeWindow();
50+
const start = 1_000_000;
51+
const asWindow = target as unknown as Window;
52+
expect(reloadOnceForStaleChunk(asWindow, start)).toBe(true);
53+
resetStaleChunkReloadForTests();
54+
expect(reloadOnceForStaleChunk(asWindow, start + 5_000)).toBe(false);
55+
resetStaleChunkReloadForTests();
56+
expect(reloadOnceForStaleChunk(asWindow, start + STALE_CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true);
57+
expect(reload).toHaveBeenCalledTimes(2);
58+
});
59+
60+
it('treats a value it cannot read as a recent reload', () => {
61+
const { target, reload } = fakeWindow();
62+
target.sessionStorage.setItem('debatelab-stale-chunk:/app/ask', 'not-a-time');
63+
expect(reloadOnceForStaleChunk(target as unknown as Window)).toBe(false);
64+
expect(reload).not.toHaveBeenCalled();
65+
});
66+
4867
it('never reloads while offline, where a missing chunk is unreachable rather than stale', () => {
4968
const { target, listeners, reload } = fakeWindow();
5069
target.navigator.onLine = false;

0 commit comments

Comments
 (0)