From 7b90409f2ed81ae6a04b21893780c9666bd63e52 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 13 Jul 2026 14:56:33 -0700 Subject: [PATCH] Add deck generation and study flow improvements --- package-lock.json | 840 ++++++- package.json | 7 +- .../migration.sql | 64 + prisma/schema.prisma | 88 +- .../spaced-repetition/[setId]/page.tsx | 5 + .../[classSlug]/spaced-repetition/page.tsx | 5 + src/app/(protected)/page.tsx | 12 + .../[id]/decks/[deckId]/route.ts | 16 + .../[id]/decks/route.ts | 43 + .../[id]/reviews/route.ts | 22 + .../api/spaced-repetition-sets/[id]/route.ts | 45 + .../[id]/study/route.ts | 15 + src/app/api/spaced-repetition-sets/route.ts | 30 + .../SpacedRepetitionSets.tsx | 365 +++ .../SpacedRepetitionViewer.tsx | 220 ++ src/components/ui/Navbar.tsx | 49 +- src/config/studyModes.ts | 1 + src/generated/prisma/browser.ts | 15 + src/generated/prisma/client.ts | 15 + src/generated/prisma/internal/class.ts | 38 +- .../prisma/internal/prismaNamespace.ts | 278 ++- .../prisma/internal/prismaNamespaceBrowser.ts | 51 +- src/generated/prisma/models.ts | 3 + src/generated/prisma/models/Class.ts | 130 ++ src/generated/prisma/models/Deck.ts | 146 ++ src/generated/prisma/models/Flashcard.ts | 137 ++ .../models/SpacedRepetitionCardState.ts | 2004 +++++++++++++++++ .../prisma/models/SpacedRepetitionSet.ts | 1776 +++++++++++++++ .../prisma/models/SpacedRepetitionSetDeck.ts | 1470 ++++++++++++ src/lib/spacedRepetition.test.ts | 121 + src/lib/spacedRepetition.ts | 162 ++ src/lib/spacedRepetitionApi.ts | 9 + src/lib/validation/spacedRepetitionSchemas.ts | 44 + src/services/classService.ts | 27 +- src/services/spacedRepetitionService.ts | 448 ++++ vitest.config.ts | 13 + 36 files changed, 8683 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260713200500_add_spaced_repetition/migration.sql create mode 100644 src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx create mode 100644 src/app/(protected)/[classSlug]/spaced-repetition/page.tsx create mode 100644 src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts create mode 100644 src/app/api/spaced-repetition-sets/[id]/decks/route.ts create mode 100644 src/app/api/spaced-repetition-sets/[id]/reviews/route.ts create mode 100644 src/app/api/spaced-repetition-sets/[id]/route.ts create mode 100644 src/app/api/spaced-repetition-sets/[id]/study/route.ts create mode 100644 src/app/api/spaced-repetition-sets/route.ts create mode 100644 src/components/spaced-repetition/SpacedRepetitionSets.tsx create mode 100644 src/components/spaced-repetition/SpacedRepetitionViewer.tsx create mode 100644 src/generated/prisma/models/SpacedRepetitionCardState.ts create mode 100644 src/generated/prisma/models/SpacedRepetitionSet.ts create mode 100644 src/generated/prisma/models/SpacedRepetitionSetDeck.ts create mode 100644 src/lib/spacedRepetition.test.ts create mode 100644 src/lib/spacedRepetition.ts create mode 100644 src/lib/spacedRepetitionApi.ts create mode 100644 src/lib/validation/spacedRepetitionSchemas.ts create mode 100644 src/services/spacedRepetitionService.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index e127911..f81787d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "ts-fsrs": "^5.4.1", "zod": "^4.4.3" }, "devDependencies": { @@ -33,7 +34,8 @@ "eslint-config-next": "16.2.9", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1349,6 +1351,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1737,6 +1749,293 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2042,6 +2341,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2051,6 +2361,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2110,6 +2427,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2790,6 +3108,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3040,6 +3471,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3395,6 +3836,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4061,6 +4512,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4555,6 +5013,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4574,6 +5042,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", @@ -4797,6 +5275,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7675,6 +8168,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -8407,6 +8914,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8739,6 +9280,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8833,6 +9381,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -9130,6 +9685,23 @@ "node": ">=6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9179,6 +9751,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9225,6 +9807,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-fsrs": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz", + "integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -9646,6 +10237,236 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9750,6 +10571,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 6ee3ab5..584aaee 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -23,6 +24,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "ts-fsrs": "^5.4.1", "zod": "^4.4.3" }, "devDependencies": { @@ -34,6 +36,7 @@ "eslint-config-next": "16.2.9", "prisma": "^7.8.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql new file mode 100644 index 0000000..abdf670 --- /dev/null +++ b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql @@ -0,0 +1,64 @@ +-- CreateTable +CREATE TABLE "SpacedRepetitionSet" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "newCardsPerDay" INTEGER NOT NULL DEFAULT 30, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SpacedRepetitionSetDeck" ( + "setId" TEXT NOT NULL, + "deckId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("setId", "deckId"), + CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SpacedRepetitionCardState" ( + "id" TEXT NOT NULL PRIMARY KEY, + "setId" TEXT NOT NULL, + "flashcardId" TEXT NOT NULL, + "due" DATETIME NOT NULL, + "stability" REAL NOT NULL, + "difficulty" REAL NOT NULL, + "elapsedDays" INTEGER NOT NULL, + "scheduledDays" INTEGER NOT NULL, + "learningSteps" INTEGER NOT NULL DEFAULT 0, + "reps" INTEGER NOT NULL, + "lapses" INTEGER NOT NULL, + "state" INTEGER NOT NULL, + "firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastReview" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId"); + +-- CreateIndex +CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due"); + +-- CreateIndex +CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt"); + +-- CreateIndex +CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be359a1..cfec0f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,9 +14,10 @@ model Class { sortOrder Int @default(0) createdAt DateTime @default(now()) - decks Deck[] - quizSets QuizSet[] - materialGroups MaterialGroup[] + decks Deck[] + quizSets QuizSet[] + materialGroups MaterialGroup[] + spacedRepetitionSets SpacedRepetitionSet[] } model Deck { @@ -27,12 +28,13 @@ model Deck { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) - groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) - cards Flashcard[] - shareLink ShareLink? - progress StudyProgress[] + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + groupId String? + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + cards Flashcard[] + shareLink ShareLink? + progress StudyProgress[] + spacedRepetitionMemberships SpacedRepetitionSetDeck[] } model Flashcard { @@ -42,7 +44,8 @@ model Flashcard { back String sortOrder Int @default(0) - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + spacedRepetitionStates SpacedRepetitionCardState[] } model QuizSet { @@ -53,9 +56,9 @@ model QuizSet { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) questions Question[] attempts QuizAttempt[] shareLink ShareLink? @@ -153,12 +156,69 @@ model MaterialGroup { id String @id @default(uuid()) classId String name String - type String // "DECK" | "QUIZ" + type String // "DECK" | "QUIZ" sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) decks Deck[] quizSets QuizSet[] shareLink ShareLink? } + +model SpacedRepetitionSet { + id String @id @default(uuid()) + classId String + name String + description String? + newCardsPerDay Int @default(30) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + decks SpacedRepetitionSetDeck[] + cardStates SpacedRepetitionCardState[] + + @@index([classId, sortOrder]) +} + +model SpacedRepetitionSetDeck { + setId String + deckId String + sortOrder Int @default(0) + addedAt DateTime @default(now()) + + set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade) + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + + @@id([setId, deckId]) + @@index([deckId]) +} + +model SpacedRepetitionCardState { + id String @id @default(uuid()) + setId String + flashcardId String + due DateTime + stability Float + difficulty Float + elapsedDays Int + scheduledDays Int + learningSteps Int @default(0) + reps Int + lapses Int + state Int + firstReviewedAt DateTime @default(now()) + lastReview DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade) + flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade) + + @@unique([setId, flashcardId]) + @@index([setId, due]) + @@index([setId, firstReviewedAt]) + @@index([flashcardId]) +} diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx new file mode 100644 index 0000000..4bd9feb --- /dev/null +++ b/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx @@ -0,0 +1,5 @@ +import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer"; + +export default function SpacedRepetitionStudyPage() { + return ; +} diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx new file mode 100644 index 0000000..b07ee3b --- /dev/null +++ b/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx @@ -0,0 +1,5 @@ +import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets"; + +export default function SpacedRepetitionPage() { + return ; +} diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index ba8de20..5d37387 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -9,6 +9,10 @@ interface ClassItem { slug: string; name: string; _count: { decks: number; quizSets: number }; + spacedRepetitionDueCards: number; + spacedRepetitionLearningCards: number; + spacedRepetitionNewCards: number; + spacedRepetitionReadyCards: number; } const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"]; @@ -114,6 +118,14 @@ export default function HomePage() {
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"} {cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"} + {cls.spacedRepetitionReadyCards > 0 && ( + + {cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready + + )}
diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts new file mode 100644 index 0000000..8abeee4 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ id: string; deckId: string }> } +) { + const { id, deckId } = await context.params; + try { + await spacedRepetitionService.removeDeck(id, deckId); + return NextResponse.json({ success: true }); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts new file mode 100644 index 0000000..805af67 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + addSpacedRepetitionDeckSchema, + reorderSpacedRepetitionDecksSchema, +} from "@/lib/validation/spacedRepetitionSchemas"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const parsed = addSpacedRepetitionDeckSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 }); + try { + return NextResponse.json( + await spacedRepetitionService.addDeck(id, parsed.data.deckId), + { status: 201 } + ); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const parsed = reorderSpacedRepetitionDecksSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 }); + try { + await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds); + return NextResponse.json({ success: true }); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts new file mode 100644 index 0000000..4e11cf8 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const parsed = reviewSpacedRepetitionCardSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 }); + try { + return NextResponse.json( + await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data }) + ); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/route.ts b/src/app/api/spaced-repetition-sets/[id]/route.ts new file mode 100644 index 0000000..721f616 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + try { + return NextResponse.json(await spacedRepetitionService.getSet(id)); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const parsed = updateSpacedRepetitionSetSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 }); + try { + return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data)); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} + +export async function DELETE( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + try { + await spacedRepetitionService.deleteSet(id); + return NextResponse.json({ success: true }); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/app/api/spaced-repetition-sets/[id]/study/route.ts b/src/app/api/spaced-repetition-sets/[id]/study/route.ts new file mode 100644 index 0000000..5addeed --- /dev/null +++ b/src/app/api/spaced-repetition-sets/[id]/study/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + try { + return NextResponse.json(await spacedRepetitionService.getStudyState(id)); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/app/api/spaced-repetition-sets/route.ts b/src/app/api/spaced-repetition-sets/route.ts new file mode 100644 index 0000000..bcc2ec3 --- /dev/null +++ b/src/app/api/spaced-repetition-sets/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; +import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; +import * as spacedRepetitionService from "@/services/spacedRepetitionService"; + +export async function GET(request: NextRequest) { + const classId = new URL(request.url).searchParams.get("classId"); + if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 }); + try { + return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId)); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + const parsed = createSpacedRepetitionSetSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 }); + } + try { + return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), { + status: 201, + }); + } catch (error) { + return spacedRepetitionErrorResponse(error); + } +} diff --git a/src/components/spaced-repetition/SpacedRepetitionSets.tsx b/src/components/spaced-repetition/SpacedRepetitionSets.tsx new file mode 100644 index 0000000..af0b9b3 --- /dev/null +++ b/src/components/spaced-repetition/SpacedRepetitionSets.tsx @@ -0,0 +1,365 @@ +"use client"; + +/* eslint-disable react-hooks/refs -- @dnd-kit intentionally returns render-time ref callbacks and drag state. */ + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + pointerWithin, + useDraggable, + useDndContext, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +interface DeckSummary { + id: string; + name: string; + description: string | null; + sortOrder?: number; + _count: { cards: number }; +} + +interface SetSummary { + id: string; + name: string; + description: string | null; + newCardsPerDay: number; + decks: DeckSummary[]; + stats: { + totalCards: number; + reviewedCards: number; + unseenCards: number; + dueCards: number; + learningAheadCards: number; + newCardsAvailable: number; + availableCards: number; + nextDue: string | null; + }; +} + +interface EditState { + id: string; + name: string; + description: string; + newCardsPerDay: number; +} + +function notifyStudyAvailabilityChanged() { + window.dispatchEvent(new Event("spaced-repetition-updated")); +} + +function SourceDeck({ deck }: { deck: DeckSummary }) { + const drag = useDraggable({ + id: `source:${deck.id}`, + data: { deckId: deck.id, sourceSetId: null }, + }); + return ( +
+ +
+

{deck.name}

+

{deck._count.cards} cards

+
+
+ ); +} + +function MembershipDeck({ deck, setId, onRemove }: { + deck: DeckSummary; + setId: string; + onRemove: () => void; +}) { + const sortable = useSortable({ + id: `set:${setId}:deck:${deck.id}`, + data: { deckId: deck.id, sourceSetId: setId, setId }, + }); + return ( +
+ +
+

{deck.name}

+

{deck._count.cards} cards

+
+ +
+ ); +} + +function SetDropZone({ set, children }: { set: SetSummary; children: React.ReactNode }) { + const drop = useDroppable({ id: `set:${set.id}`, data: { setId: set.id } }); + const { over } = useDndContext(); + const isOver = + over?.id === `set:${set.id}` || over?.data.current?.setId === set.id; + return ( +
+ {children} +
+ ); +} + +export function SpacedRepetitionSets() { + const { classSlug } = useParams<{ classSlug: string }>(); + const [classId, setClassId] = useState(""); + const [sets, setSets] = useState([]); + const [decks, setDecks] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [editing, setEditing] = useState(null); + const [message, setMessage] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const load = useCallback(async () => { + setLoading(true); + try { + const classesResponse = await fetch("/api/classes"); + const classes = (await classesResponse.json()) as Array<{ id: string; slug: string }>; + const currentClass = classes.find((item) => item.slug === classSlug); + if (!currentClass) throw new Error("Class not found"); + setClassId(currentClass.id); + const [setsResponse, decksResponse] = await Promise.all([ + fetch(`/api/spaced-repetition-sets?classId=${currentClass.id}`), + fetch(`/api/decks/list?classId=${currentClass.id}`), + ]); + if (!setsResponse.ok || !decksResponse.ok) throw new Error("Unable to load repetition sets"); + setSets(await setsResponse.json()); + setDecks(await decksResponse.json()); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Unable to load repetition sets"); + } finally { + setLoading(false); + } + }, [classSlug]); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + async function createSet() { + if (!newName.trim()) return; + const response = await fetch("/api/spaced-repetition-sets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId, name: newName, newCardsPerDay: 30 }), + }); + if (!response.ok) return setMessage("Could not create repetition set"); + setNewName(""); + setCreating(false); + await load(); + } + + async function saveEdit() { + if (!editing) return; + const response = await fetch(`/api/spaced-repetition-sets/${editing.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: editing.name, + description: editing.description, + newCardsPerDay: editing.newCardsPerDay, + }), + }); + if (!response.ok) return setMessage("Could not update repetition set"); + setEditing(null); + notifyStudyAvailabilityChanged(); + await load(); + } + + async function deleteSet(set: SetSummary) { + if (!confirm(`Delete “${set.name}”? Its spaced-repetition schedules will be permanently removed.`)) return; + const response = await fetch(`/api/spaced-repetition-sets/${set.id}`, { method: "DELETE" }); + if (!response.ok) return setMessage("Could not delete repetition set"); + notifyStudyAvailabilityChanged(); + await load(); + } + + async function removeDeck(set: SetSummary, deck: DeckSummary) { + if (!confirm(`Remove “${deck.name}” from “${set.name}”? Its scheduling history in this set will be reset.`)) return; + const response = await fetch(`/api/spaced-repetition-sets/${set.id}/decks/${deck.id}`, { method: "DELETE" }); + if (!response.ok) return setMessage("Could not remove deck"); + notifyStudyAvailabilityChanged(); + await load(); + } + + async function addDeck(setId: string, deckId: string) { + const target = sets.find((set) => set.id === setId); + const deck = decks.find((item) => item.id === deckId); + if (target?.decks.some((item) => item.id === deckId)) { + setMessage(`${deck?.name ?? "That deck"} is already in ${target.name}.`); + return; + } + const response = await fetch(`/api/spaced-repetition-sets/${setId}/decks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deckId }), + }); + if (!response.ok) return setMessage((await response.json()).error ?? "Could not add deck"); + setMessage(`${deck?.name ?? "Deck"} added.`); + notifyStudyAvailabilityChanged(); + await load(); + } + + async function handleDragEnd(event: DragEndEvent) { + const activeData = event.active.data.current as { deckId?: string; sourceSetId?: string | null } | undefined; + const overData = event.over?.data.current as { setId?: string; deckId?: string } | undefined; + if (!activeData?.deckId || !overData?.setId) return; + const targetSetId = overData.setId; + + if (activeData.sourceSetId === targetSetId && overData.deckId) { + const target = sets.find((set) => set.id === targetSetId); + if (!target) return; + const oldIndex = target.decks.findIndex((deck) => deck.id === activeData.deckId); + const newIndex = target.decks.findIndex((deck) => deck.id === overData.deckId); + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; + const ordered = arrayMove(target.decks, oldIndex, newIndex); + setSets((current) => current.map((set) => set.id === targetSetId ? { ...set, decks: ordered } : set)); + const response = await fetch(`/api/spaced-repetition-sets/${targetSetId}/decks`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deckIds: ordered.map((deck) => deck.id) }), + }); + if (!response.ok) await load(); + return; + } + await addDeck(targetSetId, activeData.deckId); + } + + if (loading) return
Loading repetition sets…
; + + return ( +
+
+

Adaptive review

Spaced repetition

+ +
+ + {message &&
{message}
} + + {creating && ( +
+ setNewName(event.target.value)} onKeyDown={(event) => event.key === "Enter" && createSet()} placeholder="e.g. Patho comprehensive review" className="min-h-11 flex-1 rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" /> + + +
+ )} + + +
+ {sets.length === 0 &&

No repetition sets yet

Create one, then drag flashcard decks into it.

} + {sets.map((set) => ( +
+
+
+

{set.name}

+ {set.description &&

{set.description}

} +
+ {set.stats.totalCards} cards + {set.stats.dueCards} due + {set.stats.learningAheadCards > 0 && {set.stats.learningAheadCards} learning} + {set.stats.newCardsAvailable} new today + Limit {set.newCardsPerDay}/day +
+
+
+ {set.stats.totalCards === 0 ? : set.stats.availableCards > 0 ? ( + {set.stats.reviewedCards === 0 ? "Study" : "Continue"} · {set.stats.availableCards} + ) : Caught up} + + +
+
+ `set:${set.id}:deck:${deck.id}`)} strategy={verticalListSortingStrategy}> + + {set.decks.length === 0 ?

Drop flashcard decks here

: set.decks.map((deck) => removeDeck(set, deck)} />)} +
+
+
+ ))} + +
+

Source library

+

Flashcard decks

+

Drag a deck into any repetition set. The original deck stays here and can be reused.

+ {decks.length ?
{decks.map((deck) => )}
:
Create flashcard decks before building a repetition set.
} +
+
+
+ + {editing && ( +
+
+

Edit repetition set

+ + setEditing({ ...editing, name: event.target.value })} className="mt-1 min-h-11 w-full rounded-lg border border-border bg-bg-surface-alt px-3 text-text-heading" /> + +