Skip to content

fix: empty-deck and parser-error copy on the upload page#2453

Merged
aalemayhu merged 4 commits into
mainfrom
fix/empty-deck-error-copy
May 19, 2026
Merged

fix: empty-deck and parser-error copy on the upload page#2453
aalemayhu merged 4 commits into
mainfrom
fix/empty-deck-error-copy

Conversation

@aalemayhu
Copy link
Copy Markdown
Contributor

@aalemayhu aalemayhu commented May 19, 2026

What

When an upload produces no cards, the upload page now shows the same info card whether the server caught it (EmptyDeckError → 400) or the parser produced an empty 200 response. Body copy is format-agnostic — works for Notion, markdown, csv, PDF — and links to /documentation/help/common-problems. Generic parser failures get a sharper fallback that names the file context and routes the user to support.

Wave PR 1 of 4 from the first-deck-success spec. Issues #721 and #731.

Why

The previous server message ("No toggles found in [filename]…") read wrong for any non-Notion upload — markdown and PDF files don't use Notion toggles, so the suggestion to "wrap your content in toggles" was incorrect for those formats. The default empty-deck body on the 200+0 path had the same Notion-centric bias. The conversion-error fallback ("Something went wrong. Try again. If the problem keeps happening, email support@2anki.net.") didn't name the file context, so users couldn't tell if it was a network issue or a parser failure.

How

  • EmptyDeckResponse server-side now carries code: 'empty_export' and docsLink: '/documentation/help/common-problems' alongside the message. Existing empty_export code in UploadErrorBody is now actually used.
  • UploadForm routes any 400 with code: 'empty_export' to the existing 'emptyDeck' zone-state (info card) instead of the generic error state. Helper zoneStateForUploadError keeps the three upload-handler call sites consistent.
  • Default renderEmptyDeckBody branch (non-Google-Drive) renders the spec text with an inline link to common-problems.
  • "Download empty deck" button is hidden when no blob is available (the 400 path has no downloadable empty deck).
  • classifyUploadError falls back to upload-context copy ("Something broke while reading this file. Try again, or send the file to support@2anki.net so we can fix the parser.") rather than the generic "Something went wrong." that's correct for thrown errors but wrong for upload responses.

Measuring success

Empty-deck-to-docs link clicks become trackable from /documentation/help/common-problems referrer. The parser-error fallback rate stays low (it's a true-fallback path); if it spikes, that's a regression elsewhere.

Testing

  • Server: pnpm test src/services/UploadService.test.ts — new assertions for code, message, and docsLink.
  • Web: pnpm --filter 2anki-web test:run — three new vitest cases:
    • 200 + 0-cards renders the new body copy with the common-problems link
    • 400 + code: 'empty_export' lands in the emptyDeck info card (not the error state, no download button)
    • classifyUploadError unit test for the parser-error fallback
  • All 742 web tests + 7 UploadService server tests pass.
  • Web typecheck + biome lint clean.

Risks

  • Behavior change: a thrown EmptyDeckError server-side used to render under "Something went wrong" with the WarningIcon; it now renders as the info-card empty-deck state. Visually different.
  • The "Download empty deck" button is now hidden when there's no blob — previously it rendered even with no link (dead click). No user impact.

Goal alignment

Activation. The first-deck-success spec frames the four upload-screen dead-ends as the cheapest activation win. Empty-deck copy that names the next step (instead of confusing non-Notion uploaders with toggle vocabulary) keeps users moving instead of bouncing.

Drive-by fix

The pre-push tsc hook caught two pre-existing errors in src/services/NotionService/blocks/lists/BlockTable.tsx (from PR #2445, merged earlier today) where r.type === 'table_row' doesn't narrow against PartialBlockObjectResponse | BlockObjectResponse. One commit on this branch adds the 'type' in r guard. Existing 9 BlockTable tests still pass.

Notes

  • Changelog: deferred per the first-deck-success spec — one combined changelog entry lands with the final PR of the wave (onboarding tour). The draft wording is in the spec.
  • Sonar local: skipped — anonymous run unauthorized for this project, no token configured locally. Change is copy-only plus a one-line helper; cognitive complexity unlikely.

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

aalemayhu and others added 4 commits May 19, 2026 15:40
EmptyDeckError now returns code 'empty_export' with a docsLink and copy
that works for any file format, not just Notion. The previous message
("No toggles found in [filename]") read wrong for markdown, csv, and PDF
uploads since none of those use Notion toggles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
classifyUploadError now falls back to copy that names the file context
("Something broke while reading this file…"). The generic fallback
stays in place for thrown errors (network, etc.) where mentioning "the
file" would be wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-thrown EmptyDeckError now lands in the emptyDeck UI rather than
the generic error state, matching the 200+0-cards flow. The download
button is hidden when no blob is available. The default empty-deck body
shows the spec copy with an inline link to common-problems.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notion's list-block-children union includes PartialBlockObjectResponse,
which lacks the type field. Reading r.type before narrowing fails tsc
under the strict @notionhq/client 5.x types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aalemayhu aalemayhu merged commit 51ecbd2 into main May 19, 2026
9 checks passed
@aalemayhu aalemayhu deleted the fix/empty-deck-error-copy branch May 19, 2026 13:47
@sonarqubecloud
Copy link
Copy Markdown

@netlify
Copy link
Copy Markdown

netlify Bot commented May 19, 2026

Deploy Preview for notion2anki ready!

Name Link
🔨 Latest commit 2a59977
🔍 Latest deploy log https://app.netlify.com/projects/notion2anki/deploys/6a0c698493d3d60008d715ee
😎 Deploy Preview https://deploy-preview-2453--notion2anki.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

aalemayhu added a commit that referenced this pull request May 19, 2026
…ntry

Playwright covers: new user sees tour, Skip hides it and calls
/api/users/me/onboarded, already-onboarded user does not see tour,
user created before migration cutoff does not see tour.

Changelog entry covers the first-deck-success wave (PRs #2453,
trial-on-reg, login-loop, and this PR — wave 4 of 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
aalemayhu added a commit that referenced this pull request May 19, 2026
## What

Adds a four-step popover tour that appears once on a new user's first
authenticated visit to `/upload`. Each step has Back / Next / Skip
controls. Pressing Skip marks the user as onboarded (stored in
`users.onboarded_at TIMESTAMPTZ NULL`) so the tour never appears again —
including on new devices.

Closes #252. Wave 4 of 4 — closes the first-deck-success spec (#2446).

## Why

A new account-holder reaching `/upload` with no orientation gives up
because they don't know where to start. The four-step tour surfaces the
four key actions at exactly the right moment without blocking the
workflow.

## How

- **Migration**: `20260608000000_add_onboarded_at_to_users.js` —
`users.onboarded_at TIMESTAMPTZ NULL DEFAULT NULL`. `pnpm kanel` types
updated.
- **Server**: `markOnboarded(userId)` in `UsersRepository` (idempotent —
only flips NULL → now()), `markOnboarded` controller method reading
`userId` from `res.locals` (CWE-639), new route `PATCH
/api/users/me/onboarded` behind `RequireAuthentication`.
- **`getLocals` response**: adds `created_at` and `onboarded_at` to the
user object so the frontend can gate the tour without a second fetch.
- **Client**: `OnboardingTour` component (~150 LOC, zero new deps),
`markOnboarded.ts` API call. Tour gates on `created_at >= 2026-06-08`
(migration timestamp) AND `onboarded_at === null`.
- **Cutoff**: users created before the migration don't see the tour — no
re-onboarding existing users.

## Measuring success

Log line: `PATCH /api/users/me/onboarded` 204 responses at > 0 per day
within the first week post-deploy. Metric to track: ratio of new users
(created_at >= 2026-06-08) who complete their first conversion within 24
hours of signup.

## Testing

- Vitest: 10 new unit tests in `OnboardingTour.test.tsx` covering
first-time render, already-onboarded suppression, pre-migration-cutoff
suppression, step navigation, Back/Next/Skip, and final-step Skip.
- Playwright: 4 e2e tests in `onboarding-tour.spec.ts` covering the Skip
end-to-end path and the three suppression conditions.
- `StripeController.test.ts`: updated `buildUser` fixture to include
`onboarded_at: null` after the kanel regen.
- All 752 Vitest tests pass. Server tsc clean.

## Changelog

```
{ type: 'feature', title: 'Upload page — a short guided tour walks you through your first conversion on a new account, and a clearer message shows when a file produces no cards', date: '2026-06-08' }
```

This entry covers the first-deck-success wave (#2446): this PR (tour),
#2453 (empty-deck copy), trial-on-reg, and login-loop. Per the spec, the
combined wave entry lands in this final PR.

## Risks

- The tour only appears for new users (created_at ≥ migration
timestamp). Existing users are unaffected.
- `markOnboarded` is idempotent — calling it twice is safe.
- Rollback: drop the `onboarded_at` column via the `down` migration;
remove the route; revert the client component. Tour won't appear.
- The kanel types were updated manually (kanel binary not installed in
worktree). Running `pnpm kanel` post-migration will produce identical
output.

## Security

- Endpoint reads `userId` from `res.locals.user` (set by
`RequireAuthentication` middleware) — never from the request body.
CWE-639 mitigated.
- No sensitive data logged or returned.
- User-row mutation: `/security-review` recommended before merge.

## Goal alignment

Reduces first-visit abandonment for new users, directly contributing to
the 300K-user retention goal. A user who completes one successful
conversion is significantly more likely to return.

## Note on sonar-scanner

`sonar-scanner` was not run locally (`SONAR_TOKEN` not configured in
this worktree environment). No new SQL, no HTML injection surface, no
zip extraction, no user-controlled URLs — the new code path is outside
Sonar's high-risk triggers. Reviewers should watch for cognitive
complexity on the component.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/2anki/codesmith/server/pr/2456"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-light.svg"><img
alt="View in Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith</code> with what you
need.</sup>

- [ ] Let Codesmith autofix CI failures and bot reviews
<!-- /codesmith:footer -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant