Imported from lushihui51/Flashy (
AGENTS.md). Install upstream withnpx skills add lushihui51/Flashy. Copyright stays with the author.
AGENTS.md
Project
- Flashy — flashcard SaaS. Frontend: React/TypeScript/Vite (
frontend/). Backend: FastAPI/SQLModel/PostgreSQL (app/) - Auth: Clerk (ADR 007). Every router depends on
CurrentUserDep(app/dependencies.py), which verifies the session JWT and yields the user every query scopes ownership by; a foreign or unknown id is a 404, never a 403. The local-only bypass (DEV_AUTH_USER_IDplusENV=development, both required) is never set outside local dev - Frontend auth:
@clerk/react(not@clerk/clerk-react) — it has no<SignedIn>/<SignedOut>; branch onuseUser()'sisLoaded/isSignedIninstead
Commands
- Frontend (in /frontend):
npm run dev; testsnpx vitest run(npm run testis watch mode and never exits); regenerate API typesnpm run gen:api - Backend:
fastapi dev; testspytest. The app never creates tables — a fresh database needsalembic upgrade headfirst (ADR 045) - Migrations:
alembic revision --autogenerate -m "message", read the generated file, thenalembic upgrade head - CI (
.github/workflows/ci.yml, ADR 041) runspytest, the alembic chain from an empty database,vitest, lint, and build on every PR and push tomain; theprotect-mainruleset requires both checks, so changes reachmainthrough PRs - Browser checks: with the ADR 007 bypass on, headless Chromium (
playwright-corein the session scratchpad, browser build in~/.cache/ms-playwright) can drive the signed-out app atlocalhost:5173against the real backend
Hard rules
- Python runs only inside the project venv (
source .venv/bin/activate, oruv run …); Python dependencies viauv, Node dependencies vianpm(in /frontend) - Never edit
frontend/src/api/types.tsby hand — regenerate it - Before committing any frontend change,
npx vitest run,npm run lint, andnpm run build(which includestsc -b) must all be clean, not just for the files you touched - Schema changes reach any persistent database only through an alembic migration; nothing under
app/callscreate_all/drop_all(ADR 045;tests/api_tests/test_schema_guard.py). Onlytests/conftest.pymay, and only againstTEST_DATABASE_URL - Mastery arithmetic (blending, scoring, aggregation) lives only in
MasteryStrategyimplementations underapp/mastery/— never in SQL, a SQLModel expression, or a trigger (ADR 012;test_no_mastery_arithmetic_outside_strategy) - A
review_group_id's rows are logged atomically, in one transaction, and never appended to afterward (ADR 011) - Timestamps are server-stamped UTC instants; the user's timezone is a rendering input only (ADR 019). Never accept a caller-supplied timestamp on a write endpoint, never store, order, or compare instants in anything but UTC, and compute every user-facing date (display, "today", day-bucketing, streaks) in
app_user.timezoneat read time - Several sessions may work in this checkout and against
TEST_DATABASE_URLat once: stage withgit add -p, commit only hunks that belong to your task, and don't start a fullpytestrun while a peer session is mid-run - Never bulk-delete rows from the local dev database as "cleanup" after a browser check — it can hold real data at any time. Leave seeded data in place
- Diagnostic reports, investigation traces, and plan-mode findings are files in
docs/cc/— never only a chat summary, never a path outside the repo (copy one in before the session ends).ccis the only directory underdocs/you may write to without asking. Each report:YYYY-MM-DD-short-slug.md, one file per investigation — never append to an earlier one; write a new one and link back- Opens with date, what prompted it, and a one-line outcome (
diagnosis only, no code changes/bug found and fixed in <commit>/deferred, see <plan>); cites code aspath:LINE-LINEand states what it does now, not what it should do - Records the decision and its reasoning — a "deferred" outcome says what would need to be true to revisit — and names any ADR, plan phase, or test assertion it contradicts or extends
Conventions
- Backend layering (ADR 034):
app/routers/api/→app/services/→app/database_ops/(one module per table pluspractice_generation.py; public functionsdb_*-prefixed, ownership scoped in the query). A service exists only where a flow spans several operations; a one-query handler calls itsdb_*function directly app/models/(ADR 046):<table>.pyholds the table, itsBase, and only that table's flatCreate/Read/Update/Summaryshapes; a shape that nests another model or describes a page/flow lives inapp/models/<router>_payloads.py. A table module imports a sibling's table class only for aRelationship, never a shape (test_models_layout_guard.py)- A rule that can drift silently gets a source-scan guard test in
tests/api_tests/; extend those rather than relying on review - Frontend dates go through
formatDate/formatDateTimeinsrc/lib/datetime.ts, which pinstimeZone; ESLint blockstoLocale*StringandIntl.DateTimeFormateverywhere else (ADR 019) - Server data is fetched through TanStack Query only (ADR 033) and lives in the query cache, never copied into long-lived component state. Reusable components and everything in
ui/never fetch: data arrives as props and the page owns the query - Extend an existing component with props rather than forking a near-duplicate; a genuinely new component is named for its purpose, never a generic name that leaves two similar components indistinguishable at the import site
- Layout: one directory per functional area under
src/components/(shell/,library/,practice/) plusui/for domain-free primitives (see the directory — the inventory drifts). Pages aresrc/pages/<Name>Page.tsx, except routed create/edit forms, which live in their area directory and take amode: 'create' | 'edit'prop (seeApp.tsx) - UI copy never echoes schema terms (ADR 021): the user-facing word set — practice, deck configuration, Prompt side / Answer side, Always shown / Random draw, Not used — is the vocabulary table in
docs/tasks/004-practice-setup.md; change the table first, then every surface. Never "practice config", "pool", or a slot name in a label, heading, button, or error string - Every non-top-level detail page carries one structural breadcrumb row above its
<h1>, pointing at its hierarchy parent however it was reached; shell destinations and create/edit forms are exempt (ADR 025). Page-view state (tabs, filters) lives in the URL, neveruseState - Round-trip return addresses ride the URL as
?returnTo=, read only throughinternalReturnTo(src/lib/returnTo.ts); one-shot arrival results ({deckId},{configurationId}) stay in router state (ADR 024) - A
.tsxfile exports only components (react-refresh/only-export-components); shared values live in a sibling.ts(ratingTiers.ts,navItems.ts) orsrc/lib/ - Imports are absolute from
src/(alias invite.config.tsandtsconfig.app.json), never relative../..chains - Routed pages render below
AppShell's sticky header — never size them withmin-h-dvh/h-screen/full-heightflex-1, or bottom controls land below the fold - Modals/sheets are Radix Dialog (ADR 016), never a hand-rolled focus trap or scroll lock. A trigger that isn't a
Dialog.Triggerdescendant needsSideDrawer.tsx'striggerRef+onPointerDownOutsidepattern, or Radix silently blocks it while the dialog is open src/api/*.tsfunctions throw viaunwrap/unwrapVoid(src/api/unwrap.ts) and never side-effect (ADR 006); a structured{code, message}detail throwsApiDetailError, which shape-aware callersinstanceof-check (ADR 022). Errors render inline at the call site — a failed query as a banner in place of its content, a failed mutation next to its control; no ErrorBoundary, global cache handlers, or toasts (ADR 035)// TODO(defer:<tag>)marks every deliberately deferred item, backend included;grep -rn "TODO(defer:" app/ frontend/src/before calling a task or PR done- Component tests: the Vitest environment is
node; a DOM test opts in per file with// @vitest-environment jsdom(ADR 017). Reusesrc/test/testUtils.tsx(renderWithRouter,renderWithProviders) andsrc/test/mocks/clerk.tsrather than re-mocking. RTL auto-cleanup doesn't fire (noglobals: true);test/setup.ts'safterEach(cleanup)does — don't remove it
Mastery model
- One
review_group_idis one appearance: aReviewGroupbundling every rated answer field and the prompt fields shown with them MasteryStrategy.expand(group)decides every(card_id, field_def_id, side)update for an appearance up front (ADR 012). Breadth (how many rated answers a prompt was shown for) changes the prompt-side update's weight, never its target — see the comment aboveEMA_BETAinapp/mastery/ema.py; both review counts increment by exactly 1 per appearance regardless- Harshest-wins must stay consistent in two places:
submit_rating(app/services/practice_run.py) fails apractice_cardif any answer field is rated 1, andEmaStrategy._aggregate_targetmakes the prompt-side target the rating-1 score if any answer failed, otherwise the mean (ADR 012) - A card's display mastery is
strategy.card_scoreover all its deck's active fields, unreviewed ones at the prior (ADR 043) — one definition for every surface. Run deltas (ADR 042) read that run's ownmastery_logrows plus one bounded latest-row-below-bound lookup per pair, never a history replay; the bound rule is the delta-semantics contract indocs/tasks/010-mastery-log.md
Entity vocabulary
The 12 tables under app/models/; the model files and the ADRs cited hold the column-level detail.
app_user— the authenticated user (keyed byclerk_user_id), root of every ownership chain;timezoneis their IANA zone, synced from theX-Timezoneheader on every request (ADR 019)subject— a user's top-level grouping of decks; ownsdeckrows.iconis a key into the curated set infrontend/src/lib/subjectIcon.ts.last_activity_at(there is noupdated_at, ADR 018) is the server-side sort key for every subject and deck list — the frontend never re-sorts — written only bytouch()inapp/services/activity.pydeck— a named collection of cards under onesubject; ownscard,field_def, anddeck_practice_configrows. Always has ≥2 active fields, at least one prompt and one answer, enforced on create, batch edit, and archive (task 003 D3). Deleting a deck cascades what it owns and preserves history (ADR 015)field_def— the sole source of truth for a field (name,FieldType,position), referenced by id everywhere; archived viaarchived_at, never hard-deleted (ADR 009, ADR 010). The active field at position 0 is the deck's primary field, derived, never stored (ADR 032)card— one flashcard in adeck; holds no content itselfcard_field_value— a card's per-field content, dense: exactly one row per activefield_defof the deck,""when unfilled, never a missing row (adding a field backfills every existing card in the same transaction); archived fields keep their rows but are excluded from every read path. An all-blank card is never persisted (task 003)review_log— append-only, immutable ledger of every rated field review, the source of truth mastery is rebuilt from; never deleted, its references goSET NULL(ADR 011, ADR 015)mastery_log— append-only ledger of(card, field)state changes; current mastery is the max-idrow per pair; a disposable projection ofreview_log, regenerated whole byrebuild_mastery(ADR 042)deck_practice_config— a saved, named, mutable template of which fields are prompts/answers and the pool-sampling rules; validated on save and again at run start (ADR 013)practice_run— one user's run (practice_sessionbefore ADR 038; the UI says "practice", ADR 021),activeorcompletedonly, spanning one or morepractice_decks; the only status transition is inget_current_practice_card(ADR 015 amended). Rerun creates a new run and keeps the original (ADR 039)practice_deck— an immutable snapshot of adeck_practice_configtaken at run start (ADR 013); outlives its deck but not its run (ADR 015);source_config_idis attribution only (ADR 040)practice_card— one generated card instance in a run (pending/passed/failed), ordered by a sparseposition(ADR 008); a failed card is requeued as a new row, never mutated (ADR 036, ADR 037); cascades with its card and its run (ADR 015)
Context
- Design decisions: see docs/adr/
- Task files (one per cycle, per-task Notes, "Superseded since" corrections): see docs/tasks/