Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions Documentation/specs/first-deck-success.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
## Spec: First-deck-success activation wave

### Trio synthesis

- **PM**: Activation is the funnel stage with the worst leakage today — four filed bugs/holes (#2362, #721+#731, #2352, #252) each kill a percentage of first-deck attempts. Ship them together so the wave is measurable as one activation jump, not four scattered points.
- **Designer**: The user moment is "I uploaded my notes and I don't have my deck yet." Every dead-end here is a copy or flow problem, not a visual one. The trial-on-registration single primary action and the empty-deck error rewrite carry most of the lift; onboarding popovers come last and stay skippable.
- **Engineer**: Four loosely-coupled changes, mostly in `web/`. The only server work is one new signup-with-trial atomic endpoint (or query-param flag on existing `/users/signup`). Login-loop fix is local to `LoginPage` + the cookie clear; error-copy fix is a string change + a small UI block. Onboarding is the largest unknown — recommend driver.js or a 4-step home-grown tour, not a library bake-off.
- **Agreement**: All three want the four issues shipped as one wave with one changelog entry. All three flag #252 (onboarding) as the most expandable scope and want it bounded to four popovers + Skip.
- **Conflict**: PM wanted to include a "your file is saved" affordance across the registration redirect; engineer pushed back on the storage primitive (sessionStorage vs server-side draft). Resolved by keeping the file-preserve behavior in scope but constraining the implementation to sessionStorage of the existing `limitInfo.filename` reference — re-prompt to re-attach after login, do not server-side-stash binary uploads.
- **Resulting plan**: Four changes, one PR per change, merged in order: empty-deck copy → login-loop fix → trial-on-registration → onboarding. The wave ships under one changelog entry once all four are in main.

---

**Outcome**: Lift first-deck-success rate (anonymous visit → downloaded `.apkg`) measurably. Target: +5 percentage points across the four sub-flows combined, measured over the 14 days after the last of the four merges. Leading indicators that should move: anonymous-upload-to-registration conversion (#2362), conversion-success rate for users who upload an empty Notion export (#721+#731), returning-user login success (#2352), and 7-day retention for new accounts (#252).

**Goal alignment**: Activation is the funnel stage that compounds hardest against the 300K-user goal — every dead-end in the first 30 seconds costs a user we already paid to acquire. Four filed, reproducible dead-ends fixed together is the cheapest activation win on the board.

**Problem**: A new visitor arrives, uploads their notes, and hits one of four cliffs: (a) anonymous user hits the 100-card limit, registers, immediately hits the same limit again because the 1-hour trial CTA is only shown post-registration (#2362); (b) the upload converts but produces no cards, and the user sees "Could not create any cards. Did you write any togglelists?" — a hostile string referencing an internal concept (#721, #731); (c) a returning user with a stale or invalidated session cookie lands in an infinite redirect loop between `/upload` and `/login`, looking like the site is down (#2352); (d) a new account-holder reaches the upload page with no orientation and gives up because they don't know where to start (#252). Each cliff is independent — fixing one doesn't fix the others — and they compound.

**Riskiest assumption**: That activation lifts compound across the four fixes — i.e. that the user who would have bounced at the limit-screen dead-end is *also* the same user who would have bounced at the empty-deck error, the login loop, or the unguided upload screen. If the cliffs hit non-overlapping user segments, the combined lift is the sum of four small numbers, not one big one. Either way the work is worth doing, but the wave framing assumes leverage.

**Smallest test**: Before any UI work, instrument the existing four touchpoints with a one-line analytics event each (already partially in place for upload + register). Run for one week, count drop-off at each step, confirm the four points are in fact the top-four leak points. If a fifth, larger leak shows up (e.g. checkout, OAuth callback) the wave reprioritises.

**Scope (in)**:
- #2362 — anonymous trial-on-registration: single primary CTA "Create account + start 1-hour trial" on the anonymous limit screen, atomic signup-then-startTrial on the server, file reference preserved across the redirect via sessionStorage.
- #721 + #731 — replace the "Could not create any cards. Did you write any togglelists?" string with copy that names the surface, gives a next step, and points at `/documentation/help/common-problems` for the deeper case (the docs page already covers it).
- #2352 — `/login` distinguishes "no cookie" from "invalid cookie": invalid cookie is cleared on render, login form renders once, no further redirect. Add a vitest + Playwright case for the malformed-JWT path.
- #252 — four-step popover tour on first authenticated visit to `/upload`: "Drop a file or pick Notion" → "Pick your deck settings" → "Convert" → "Download or send to AnkiWeb". A persistent **Skip** button on every step. Stored on the user row, not localStorage, so it doesn't reappear on a new device.

**Scope (out)**:
- Showing the trial CTA on any limit screen other than the upload-limit message (paywall banner, top message). Handle in a follow-up if needed.
- Paid pass upsell at the limit screen. Separate question.
- A user-facing "You were signed out" notice on the login-loop fix. Nice to have, file separately.
- Detecting *why* the cookie is invalid (rotation, tampering, expiry). Treat all invalid cookies the same.
- Onboarding for surfaces other than `/upload` (downloads, settings, account). The first deck is the only thing that matters for activation.
- Bet 5's Note.ts dedupe work — left untouched.

**User story**: As a new visitor who just uploaded my notes, I want to land on a downloaded Anki deck within 30 seconds, even when the first thing I tried hit a limit, returned no cards, or my old session expired — without having to read documentation, contact support, or guess what "togglelist" means.

**Acceptance criteria**:

#2362 — Trial on registration
- [ ] Anonymous upload-limit screen shows one primary CTA: "Create account and start trial" (sentence case, no exclamation, no "my").
- [ ] CTA links to `/register?redirect=/upload&start_trial=1`.
- [ ] On registration with `start_trial=1`, the server atomically creates the account and starts the trial — one request, one cookie set, no second click required.
- [ ] If the registering email already had `trial_started_at != null` (e.g. previously-deleted account being recreated), the CTA falls back to "Create account" silently — don't promise a trial we won't give.
- [ ] The originally-uploaded filename is preserved across the redirect (sessionStorage), and a one-line banner on the post-register `/upload` page reads "Re-attach <filename> to convert."
- [ ] If the user navigates away from `/register` without completing, the sessionStorage reference is left intact for the session; cleared on next successful upload.

#721 + #731 — Empty-deck error copy
- [ ] The string "Could not create any cards. Did you write any togglelists?" is removed from every code path.
- [ ] Replacement copy, surfaced where the old one was: **"No cards were found in this file. Most files need a toggle-list (Notion) or a question/answer pair to become cards. See common problems for the formats that work."** — link "common problems" to `/documentation/help/common-problems`.
- [ ] When the failure is an outright conversion error (not "zero cards"), the message becomes **"Something broke while reading this file. Try again, or send the file to support@2anki.net so we can fix the parser."** (#731).
- [ ] Both messages render as the contents of an info card, not a toast. They persist on the page until the user retries.
- [ ] Vitest covers both strings rendering in the relevant states.

#2352 — Login-loop fix
- [ ] Visiting any protected route with a malformed or expired session cookie bounces to `/login` exactly once. The cookie is cleared on that render.
- [ ] Visiting `/login` with no cookie renders the form once with no redirect.
- [ ] The guard distinguishes "no cookie" (render) from "invalid cookie" (clear + render) — not by presence alone.
- [ ] Vitest covers the cookie-cleared path; Playwright covers the malformed-JWT loop-prevention scenario.

#252 — First-upload onboarding
- [ ] A four-step popover tour appears on a user's first authenticated visit to `/upload`.
- [ ] Each step has a **Skip** button (not "Dismiss", not "Close"); pressing Skip flags the user as onboarded for all future visits.
- [ ] The tour does not appear for users with `created_at` more than 24 hours before the feature ships (i.e. don't re-onboard existing users).
- [ ] The onboarded flag is stored on the user row, not localStorage. New migration: `users.onboarded_at TIMESTAMPTZ NULL`.
- [ ] On a fresh device, an already-onboarded user does not see the tour.
- [ ] Step copy follows VOICE.md: imperative, sentence case, no exclamation marks, no "Let's get started", no fake warmth.

**Cross-cutting**:
- [ ] One changelog entry for the wave, written after the last PR merges. Draft: **"First upload is smoother — clearer error when a file has no cards, a single button to create your account and start your trial, and a quick tour on your first visit."** (revise to match `changelog.ts` on the day; one line, no period, sentence case, no banned words.)
- [ ] `pnpm test`, `pnpm --filter 2anki-web test`, `pnpm --filter 2anki-web typecheck`, and `pnpm --filter 2anki-web lint` all clean before each PR ships.

**Open questions**:
1. **Trial-on-registration atomicity**: should it be a new `/users/signup-with-trial` endpoint, or a `start_trial=1` flag on the existing signup? Engineer leans to the flag — fewer routes, less surface area to test. PM doesn't care as long as it's one request.
2. **Empty-deck vs conversion-error distinction**: today the upload path returns the same shape for "0 cards" and "parser threw". Does the parser already differentiate at the boundary, or does engineer need to add a discriminator? Confirm before writing the copy.
3. **Onboarding library vs hand-rolled**: driver.js is the obvious pick (small, framework-agnostic) but adds a dep. Hand-rolled four-step is ~150 LOC and zero deps. Pick before starting.
4. **Sessionstorage filename preserve**: is it safe to assume `limitInfo.filename` is non-PII enough to keep in sessionStorage? It's a user-chosen filename — should be fine, but flag for review.
5. **#2352 root cause**: the issue body lists `SECRET` rotation, OAuth client rotation, and CSP regression (#2306/#2307) as triggers. Confirm none of these recur with the fix in place — the loop guard is the symptom-level fix, not the root cause.

**Out of scope (next iteration)**:
- Server-side draft storage of uploaded files across login (today the file is re-prompted; binary draft persistence is a separate feature).
- A "Continue with Google" / "Continue with Notion" path on the trial-on-registration CTA — the wave keeps the existing register form for now.
- Re-running the onboarding tour on demand (a `/onboarding` page or "Show me the tour again" link). File separately if anyone asks.
- Localised copy for the empty-deck error and onboarding steps. English only for the first ship.
- Expanding onboarding to the downloads page, account page, or Anki Sync page. First deck only.

---

### Design notes

**The user moment.** Every fix targets the same instant — "I uploaded my notes and I don't have my deck yet." The user is task-focused, not exploring; the right response is a single primary action that names what they get.

**One primary action per dead-end.** All four screens collapse to one CTA, no "or" buttons, no equal-weight secondaries:

| Screen | Primary CTA | Notes |
|---|---|---|
| Anonymous upload limit | **Create account and start trial** | Sentence case. No "my". The "1-hour" qualifier moves into the helper line below ("Convert any file for the next hour."), not the button. |
| Empty-deck error | **Read common problems** | Secondary "Try a different file" as a link, not a button. |
| Login loop fixed | (no new CTA — the existing **Log in** button is what they want; the fix is that they see it.) | Optional small text: "Your session ended. Sign in to continue." — flagged as out-of-scope. |
| First-upload tour | **Next** / **Skip** on every step | Skip is a text button to the right of Next, same weight as the close X — visible, not hidden. |

**Copy strings (subject to VOICE.md, sentence case, no period on button labels, no banned words):**

- Limit screen primary: **Create account and start trial**
- Limit screen helper line: **Convert any file for the next hour.**
- Limit screen fallback (trial already used): **Create account**
- Empty-deck info card: **No cards were found in this file. Most files need a toggle-list (Notion) or a question/answer pair to become cards. See common problems for the formats that work.**
- Conversion-error info card: **Something broke while reading this file. Try again, or send the file to support@2anki.net so we can fix the parser.**
- Tour step 1: **Drop a file, or pick a Notion page.** — supported formats listed under.
- Tour step 2: **Pick deck settings.** — short line.
- Tour step 3: **Convert your file into a deck.**
- Tour step 4: **Download the deck, or send it to AnkiWeb.**
- Tour controls: **Next**, **Back**, **Skip** (no "Dismiss", no "Got it!", no exclamation marks).

**Empty-state vs error-state.** The empty-deck info card is *not* a toast — it persists in place of the result. The trial-on-registration banner on the post-login `/upload` ("Re-attach <filename> to convert.") *is* persistent until the file is re-uploaded; it's not dismissible because the dismiss action would lose state with no recovery.

**Visual restraint.** No new colors, no new icons specifically for this wave. Use existing info-card and primary-button tokens. Onboarding popover uses the existing card surface and the primary button — no themed tooltip styling.

**Verdict**: no scope changes to the pm spec. Copy and primary-action choices above are the design contribution.

---

### Technical pre-flight

**Layers touched**:
- `web/` — most of the work (upload form CTAs, empty-deck info card, login redirect guard, onboarding tour).
- `src/routes/UserRouter.ts` + `src/controllers/UsersControllers.ts` — extend the existing signup handler to accept and honor `start_trial=1`; verify `startTrial` is idempotent. No new route preferred.
- `src/data_layer/` — one migration for `users.onboarded_at TIMESTAMPTZ NULL`. Regenerate types with `pnpm kanel`.
- No changes in `src/services/` or `src/lib/parser/` — the empty-deck case already reaches the boundary; we're rewriting the message, not the detection.

**Files likely in play**:
- `web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx` (lines 259, 733–757, 927, 954 — the existing `showTrialButton`/`limitInfo` logic). Simplify the anonymous branch to a single CTA.
- `web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx` — adjust the 6+ tests that pin the current two-CTA shape.
- `web/src/components/forms/RegisterForm.tsx` — read `start_trial` query param, forward to backend.
- `web/src/pages/LoginPage/components/LoginForm/helpers/useHandleLoginSubmit.ts` and `web/src/pages/LoginPage/LoginPage.tsx` — invalid-cookie distinguish + clear logic.
- `web/src/lib/backend/api.ts` — `redirectToLogin` currently checks pathname; extend to handle the invalid-cookie clear path, or add a sibling helper.
- `web/src/pages/DocsPage/content/help/common-problems.md` — already covers the empty-deck case; verify the deep link.
- New: `web/src/pages/UploadPage/components/OnboardingTour/` — four-step popover + Skip + `onboarded_at` PATCH.
- `src/controllers/UsersControllers.ts` — `signup` path: accept `start_trial`, call `startTrial` in the same request after the user row + auth cookie are committed. Wrap both in a transaction or run sequentially with a rollback path on the trial-start failure (engineer recommends sequential + log on trial-start failure; account creation is the more valuable side effect, don't fail the signup if trial start fails).
- `src/routes/UserRouter.ts` — endpoint to PATCH `users.onboarded_at`.
- New migration: `migrations/<timestamp>_user_onboarded_at.js` via `npx knex migrate:make user_onboarded_at --knexfile ./src/KnexConfig.ts --migrations-directory ../migrations -x js`, then `pnpm kanel`.

**Cross-language coordination**: None. All TypeScript both sides.

**Estimated effort**:
- #721+#731 — **S**. String change in 1–2 files, 2 new test cases, copy review.
- #2352 — **S**. Local to `LoginPage` + `api.ts`; one vitest, one Playwright.
- #2362 — **M**. Server-side change to signup, sessionStorage glue, register-form query param plumbing, 3+ test updates, security review for the `start_trial` flag (must verify the signup path can't be tricked into starting a trial without an actual sign-up).
- #252 — **M**. New component, migration, kanel regen, 2 endpoints (read + PATCH), 4 vitest cases, 1 Playwright covering the Skip path.

Wave total: **M+**. Recommend four PRs, smallest first (copy → login-loop → trial-on-reg → onboarding), each off the freshly-updated `main`.

**Security**:
- `start_trial=1` query param must only take effect inside the same atomic signup request — never on an arbitrary "start my trial" GET. The existing `startTrial` controller already gates on auth — confirm there's no path where an attacker can pass `start_trial=1` on someone else's signup. CWE-862 to verify.
- Onboarded-at PATCH endpoint must require auth and only allow the authenticated user to update their own row (no `userId` in the body — pull from `res.locals`). CWE-639.
- SessionStorage filename: it's user-chosen text; render any banner that displays it through the existing safe-rendering pipeline. No HTML interpolation. CWE-79.
- Login-loop clear path must use `Set-Cookie` with `HttpOnly; Secure; SameSite=Lax; Max-Age=0` exactly matching the original cookie attributes — wrong attributes can fail to clear in some browsers and re-trigger the loop the fix is supposed to remove. CWE-614.

**Testing**:
- Outside-in: drive every test from the form/page boundary, not the internal hooks. Per `.claude/rules/testing.md`, mock only HTTP at the SDK boundary.
- Failing-test-first for the login-loop fix — reproduce the loop in vitest before fixing.
- Empty-deck error: two `it.each` cases for the two messages.
- Onboarding: cover both first-time-render and already-onboarded-don't-render paths.

**Migrations**:
- One: `users.onboarded_at TIMESTAMPTZ NULL`, default null. Backfill: leave null (existing users don't see the tour because the front-end gates on `created_at` window, not on `onboarded_at`).
- Regenerate types with `pnpm kanel`. Do not hand-edit `src/data_layer/public/`.

**Sonar**:
- Trial-on-registration handler will likely trip cognitive-complexity if the signup path grows another conditional. Plan to factor the "with-trial" tail into a small helper before pushing.
- The new login-guard branch must lead with the positive ("if (cookie && valid)") per Sonar S7735.
- Run `sonar-scanner` locally before each `gh pr ready`.

**Out of scope (engineering)**:
- A retry-this-file affordance after the empty-deck error (would require keeping the upload in temp storage past the response — separate spec).
- A graceful in-tab toast for the login-loop "you were signed out" message (would couple front-end auth state to the cookie clear timing — defer).