Add deck generation and study flow improvements
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m22s

This commit is contained in:
Elijah 2026-07-13 14:56:33 -07:00
parent d6f3502cb1
commit 7b90409f2e
36 changed files with 8683 additions and 31 deletions

840
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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");

View file

@ -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])
}

View file

@ -0,0 +1,5 @@
import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer";
export default function SpacedRepetitionStudyPage() {
return <SpacedRepetitionViewer />;
}

View file

@ -0,0 +1,5 @@
import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets";
export default function SpacedRepetitionPage() {
return <SpacedRepetitionSets />;
}

View file

@ -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() {
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
{cls.spacedRepetitionReadyCards > 0 && (
<span
className="rounded-full bg-error-bg px-2.5 py-1 text-error"
title={`${cls.spacedRepetitionDueCards} due, ${cls.spacedRepetitionLearningCards} learning, ${cls.spacedRepetitionNewCards} new`}
>
{cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready
</span>
)}
</div>
</Link>
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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 (
<div
ref={drag.setNodeRef}
style={{ transform: CSS.Translate.toString(drag.transform), opacity: drag.isDragging ? 0.45 : 1 }}
className="flex min-w-0 items-center gap-3 rounded-xl border border-border-light bg-bg-surface p-4 shadow-sm"
>
<button
{...drag.attributes}
{...drag.listeners}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"
aria-label={`Drag ${deck.name} into a repetition set`}
>
<span aria-hidden></span>
</button>
<div className="min-w-0">
<p className="truncate font-bold text-text-heading">{deck.name}</p>
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
</div>
</div>
);
}
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 (
<div
ref={sortable.setNodeRef}
style={{
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
opacity: sortable.isDragging ? 0.4 : 1,
}}
className="flex min-w-0 items-center gap-2 rounded-xl border border-border-light bg-bg-surface-alt/55 p-3"
>
<button
{...sortable.attributes}
{...sortable.listeners}
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-bg-surface hover:text-text-heading"
aria-label={`Reorder or copy ${deck.name}`}
>
<span aria-hidden></span>
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-text-heading">{deck.name}</p>
<p className="text-xs text-text-muted">{deck._count.cards} cards</p>
</div>
<button
onClick={onRemove}
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error"
aria-label={`Remove ${deck.name} from this repetition set`}
>
×
</button>
</div>
);
}
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 (
<div
ref={drop.setNodeRef}
className={`grid min-h-20 grid-cols-1 gap-3 rounded-xl border-2 border-dashed p-3 sm:grid-cols-2 ${
isOver ? "border-primary bg-primary/5" : "border-border-light"
}`}
>
{children}
</div>
);
}
export function SpacedRepetitionSets() {
const { classSlug } = useParams<{ classSlug: string }>();
const [classId, setClassId] = useState("");
const [sets, setSets] = useState<SetSummary[]>([]);
const [decks, setDecks] = useState<DeckSummary[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [editing, setEditing] = useState<EditState | null>(null);
const [message, setMessage] = useState<string | null>(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 <div className="animate-subtle-pulse py-16 text-center text-text-muted">Loading repetition sets</div>;
return (
<div className="pb-20">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Adaptive review</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Spaced repetition</h2></div>
<button onClick={() => setCreating(true)} className="min-h-12 rounded-xl bg-primary px-5 font-bold text-white shadow-sm hover:bg-primary-hover">New repetition set</button>
</div>
{message && <div className="mb-5 flex items-center justify-between rounded-xl border border-primary/20 bg-bg-callout px-4 py-3 text-sm text-text-heading"><span>{message}</span><button onClick={() => setMessage(null)} aria-label="Dismiss message">×</button></div>}
{creating && (
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 sm:flex-row">
<input autoFocus value={newName} onChange={(event) => 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" />
<button onClick={createSet} className="rounded-lg bg-primary px-5 font-bold text-white">Create</button>
<button onClick={() => { setCreating(false); setNewName(""); }} className="rounded-lg border border-border px-5 font-bold text-text-heading">Cancel</button>
</div>
)}
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
<div className="space-y-6">
{sets.length === 0 && <div className="rounded-2xl border border-dashed border-border p-10 text-center"><h3 className="font-bold text-text-heading">No repetition sets yet</h3><p className="mt-1 text-sm text-text-secondary">Create one, then drag flashcard decks into it.</p></div>}
{sets.map((set) => (
<section key={set.id} className="rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<h3 className="text-xl font-extrabold text-text-heading">{set.name}</h3>
{set.description && <p className="mt-1 text-sm text-text-secondary">{set.description}</p>}
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-badge-bg px-2.5 py-1 text-badge-text">{set.stats.totalCards} cards</span>
<span className="rounded-full bg-error-bg px-2.5 py-1 text-error">{set.stats.dueCards} due</span>
{set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-2.5 py-1 text-primary">{set.stats.learningAheadCards} learning</span>}
<span className="rounded-full bg-success-bg px-2.5 py-1 text-success">{set.stats.newCardsAvailable} new today</span>
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1 text-text-muted">Limit {set.newCardsPerDay}/day</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{set.stats.totalCards === 0 ? <button disabled className="min-h-10 rounded-lg bg-bg-surface-alt px-5 text-sm font-bold text-text-muted">Add decks to study</button> : set.stats.availableCards > 0 ? (
<Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">{set.stats.reviewedCards === 0 ? "Study" : "Continue"} · {set.stats.availableCards}</Link>
) : <Link href={`/${classSlug}/spaced-repetition/${set.id}`} className="inline-flex min-h-10 items-center rounded-lg bg-success-bg px-5 text-sm font-bold text-success">Caught up</Link>}
<button onClick={() => setEditing({ id: set.id, name: set.name, description: set.description ?? "", newCardsPerDay: set.newCardsPerDay })} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-bg-surface-alt hover:text-text-heading" aria-label={`Edit ${set.name}`}></button>
<button onClick={() => deleteSet(set)} className="grid h-10 w-10 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" aria-label={`Delete ${set.name}`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<SortableContext items={set.decks.map((deck) => `set:${set.id}:deck:${deck.id}`)} strategy={verticalListSortingStrategy}>
<SetDropZone set={set}>
{set.decks.length === 0 ? <p className="col-span-full py-4 text-center text-sm text-text-muted">Drop flashcard decks here</p> : set.decks.map((deck) => <MembershipDeck key={deck.id} deck={deck} setId={set.id} onRemove={() => removeDeck(set, deck)} />)}
</SetDropZone>
</SortableContext>
</section>
))}
<section className="mt-10 border-t border-border-light pt-8">
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Source library</p>
<h3 className="editorial-title mt-1 text-2xl text-text-heading">Flashcard decks</h3>
<p className="mb-5 mt-1 text-sm text-text-secondary">Drag a deck into any repetition set. The original deck stays here and can be reused.</p>
{decks.length ? <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">{decks.map((deck) => <SourceDeck key={deck.id} deck={deck} />)}</div> : <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-text-muted">Create flashcard decks before building a repetition set.</div>}
</section>
</div>
</DndContext>
{editing && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
<h3 className="editorial-title text-2xl text-text-heading">Edit repetition set</h3>
<label className="mt-5 block text-sm font-bold text-text-heading">Name</label>
<input value={editing.name} onChange={(event) => 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" />
<label className="mt-4 block text-sm font-bold text-text-heading">Description</label>
<textarea value={editing.description} onChange={(event) => setEditing({ ...editing, description: event.target.value })} rows={3} className="mt-1 w-full resize-none rounded-lg border border-border bg-bg-surface-alt p-3 text-text-heading" />
<label className="mt-4 block text-sm font-bold text-text-heading">New cards per day</label>
<input type="number" min={0} max={999} value={editing.newCardsPerDay} onChange={(event) => setEditing({ ...editing, newCardsPerDay: Number(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" />
<p className="mt-1 text-xs text-text-muted">Due reviews are never capped. Set this to 0 to pause new cards.</p>
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditing(null)} className="min-h-10 rounded-lg border border-border px-4 font-bold text-text-heading">Cancel</button><button onClick={saveEdit} className="min-h-10 rounded-lg bg-primary px-4 font-bold text-white">Save</button></div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,220 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { SpacedRepetitionRating } from "@/lib/spacedRepetition";
interface StudyState {
set: {
id: string;
name: string;
description: string | null;
class: { slug: string; name: string };
stats: {
totalCards: number;
reviewedCards: number;
unseenCards: number;
dueCards: number;
learningAheadCards: number;
newCardsAvailable: number;
availableCards: number;
nextDue: string | null;
};
};
currentCard: {
id: string;
front: string;
back: string;
sourceDeckName: string;
stateVersion: string | null;
previews: Array<{
rating: SpacedRepetitionRating;
due: string;
scheduledDays: number;
}>;
} | null;
}
const ratingStyles: Record<SpacedRepetitionRating, string> = {
AGAIN: "border-error/35 text-error hover:bg-error-bg",
HARD: "border-accent/40 text-accent hover:bg-bg-surface-alt",
GOOD: "border-success/35 text-success hover:bg-success-bg",
EASY: "border-primary/35 text-primary hover:bg-bg-callout",
};
function intervalLabel(due: string, scheduledDays: number) {
if (scheduledDays >= 1) {
if (scheduledDays < 30) return `${scheduledDays}d`;
const months = Math.round(scheduledDays / 30);
if (months < 12) return `${months}mo`;
return `${Math.round(months / 12)}y`;
}
const minutes = Math.max(1, Math.round((new Date(due).getTime() - Date.now()) / 60000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
return `${hours}h`;
}
function dueLabel(due: string | null) {
if (!due) return "No reviews scheduled";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(due));
}
export function SpacedRepetitionViewer() {
const { classSlug, setId } = useParams<{ classSlug: string; setId: string }>();
const router = useRouter();
const [data, setData] = useState<StudyState | null>(null);
const [loading, setLoading] = useState(true);
const [flipped, setFlipped] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const response = await fetch(`/api/spaced-repetition-sets/${setId}/study`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Repetition set not found");
const nextData = (await response.json()) as StudyState;
if (nextData.set.class.slug !== classSlug) throw new Error("Repetition set not found");
setData(nextData);
setFlipped(false);
setError(null);
} catch {
router.push(`/${classSlug}/spaced-repetition`);
} finally {
setLoading(false);
}
}, [classSlug, router, setId]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
const submitRating = useCallback(async (rating: SpacedRepetitionRating) => {
if (!data?.currentCard || saving) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/spaced-repetition-sets/${setId}/reviews`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
flashcardId: data.currentCard.id,
rating,
expectedStateVersion: data.currentCard.stateVersion,
}),
});
if (response.status === 409) {
await load();
setError("That review was already saved. The queue has been refreshed.");
return;
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error ?? "Unable to save review");
}
setData(await response.json());
window.dispatchEvent(new Event("spaced-repetition-updated"));
setFlipped(false);
} catch (reviewError) {
setError(reviewError instanceof Error ? reviewError.message : "Unable to save review");
} finally {
setSaving(false);
}
}, [data, load, saving, setId]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (!data?.currentCard || saving) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
setFlipped((current) => !current);
return;
}
if (!flipped) return;
const keyboardRatings: Record<string, SpacedRepetitionRating> = {
"1": "AGAIN", "2": "HARD", "3": "GOOD", "4": "EASY",
};
const rating = keyboardRatings[event.key];
if (rating) submitRating(rating);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [data, flipped, saving, submitRating]);
if (loading || !data) {
return <div className="mx-auto max-w-5xl animate-subtle-pulse py-16 text-center text-text-muted">Loading review queue</div>;
}
const card = data.currentCard;
return (
<div className="mx-auto w-full max-w-5xl py-1 pb-16 md:py-3">
<header className="mb-6 rounded-2xl border border-border-light bg-bg-surface/75 p-4 shadow-[var(--shadow-card)] sm:p-5">
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mb-2 inline-flex min-h-10 items-center gap-2 text-sm text-text-muted hover:text-text-heading"><span aria-hidden></span> Back to repetition sets</button>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Spaced repetition</p><h1 className="editorial-title mt-1 text-3xl text-text-heading">{data.set.name}</h1>{data.set.description && <p className="mt-1 text-sm text-text-secondary">{data.set.description}</p>}</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-error-bg px-3 py-1.5 text-error">{data.set.stats.dueCards} due</span>
{data.set.stats.learningAheadCards > 0 && <span className="rounded-full bg-bg-callout px-3 py-1.5 text-primary">{data.set.stats.learningAheadCards} learning</span>}
<span className="rounded-full bg-success-bg px-3 py-1.5 text-success">{data.set.stats.newCardsAvailable} new</span>
<span className="rounded-full bg-bg-surface-alt px-3 py-1.5 text-text-muted">{data.set.stats.reviewedCards}/{data.set.stats.totalCards} introduced</span>
</div>
</div>
</header>
{error && <div role="alert" className="mb-5 flex items-center justify-between rounded-xl border border-error/30 bg-error-bg px-4 py-3 text-sm text-error"><span>{error}</span><button onClick={() => setError(null)} aria-label="Dismiss error">×</button></div>}
{!card ? (
<div className="rounded-3xl border border-success/25 bg-bg-surface p-8 text-center shadow-[var(--shadow-card)] sm:p-12">
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full bg-success-bg text-3xl text-success"></div>
<h2 className="editorial-title mt-5 text-3xl text-text-heading">Youre caught up</h2>
<p className="mt-2 text-text-secondary">There are no due cards and todays new-card allowance is complete.</p>
<p className="mt-5 text-sm font-semibold text-text-muted">Next review: {dueLabel(data.set.stats.nextDue)}</p>
<button onClick={() => router.push(`/${classSlug}/spaced-repetition`)} className="mt-7 min-h-11 rounded-xl bg-primary px-6 font-bold text-white hover:bg-primary-hover">Back to sets</button>
</div>
) : (
<div className="flex flex-col items-center">
<div className="mb-4 flex w-full items-center justify-between text-sm text-text-muted"><span>From {card.sourceDeckName}</span><span>{data.set.stats.availableCards} available</span></div>
<div
role="button"
tabIndex={0}
aria-label={flipped ? "Showing card answer. Press Space to show front." : "Showing card front. Press Space to reveal answer."}
onClick={() => !saving && setFlipped((current) => !current)}
className="perspective-1000 relative w-full cursor-pointer select-none"
>
<div className={`preserve-3d relative min-h-[420px] w-full transition-transform duration-300 md:min-h-[560px] ${flipped ? "rotate-x-180" : ""}`}>
<div className="paper-grid backface-hidden absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Front</span>
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.front}</ReactMarkdown></div>
<p className="mt-7 text-xs text-text-muted">Tap or press Space to reveal the answer</p>
</div>
<div className="paper-grid backface-hidden absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] md:p-12">
<span className="absolute right-5 top-4 text-xs font-bold uppercase tracking-wider text-text-muted">Back</span>
<div className="markdown-content max-h-[390px] w-full overflow-y-auto text-center text-lg leading-relaxed text-text-body md:max-h-[450px] md:text-xl"><ReactMarkdown remarkPlugins={[remarkGfm]}>{card.back}</ReactMarkdown></div>
</div>
</div>
</div>
<div className={`mt-6 grid w-full grid-cols-2 gap-2 transition-opacity sm:grid-cols-4 ${flipped ? "opacity-100" : "pointer-events-none opacity-0"}`} aria-hidden={!flipped}>
{card.previews.map((preview, index) => (
<button key={preview.rating} disabled={saving || !flipped} onClick={() => submitRating(preview.rating)} className={`min-h-16 rounded-xl border-2 bg-bg-surface px-3 py-2 text-sm font-extrabold transition-colors disabled:opacity-50 ${ratingStyles[preview.rating]}`}>
<span className="block">{preview.rating[0]}{preview.rating.slice(1).toLowerCase()}</span>
<span className="mt-0.5 block text-xs font-semibold opacity-75">{index + 1} · {intervalLabel(preview.due, preview.scheduledDays)}</span>
</button>
))}
</div>
<p className="mt-4 text-center text-xs text-text-muted">{flipped ? "Choose 14 based on how well you remembered" : "Space to flip"}</p>
</div>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
@ -19,6 +19,37 @@ export function Navbar() {
const [open, setOpen] = useState(false);
const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
const [hasReadyCards, setHasReadyCards] = useState(false);
const refreshDueCards = useCallback(async () => {
if (!classSlug) return;
try {
const response = await fetch("/api/classes");
if (!response.ok) return;
const classes = await response.json() as Array<{
slug: string;
spacedRepetitionReadyCards: number;
}>;
const currentClass = classes.find((classItem) => classItem.slug === classSlug);
setHasReadyCards((currentClass?.spacedRepetitionReadyCards ?? 0) > 0);
} catch {
// Keep the last known state when a background refresh fails.
}
}, [classSlug]);
useEffect(() => {
const initialRefresh = window.setTimeout(() => void refreshDueCards(), 0);
const interval = window.setInterval(refreshDueCards, 60_000);
window.addEventListener("focus", refreshDueCards);
window.addEventListener("spaced-repetition-updated", refreshDueCards);
return () => {
window.clearTimeout(initialRefresh);
window.clearInterval(interval);
window.removeEventListener("focus", refreshDueCards);
window.removeEventListener("spaced-repetition-updated", refreshDueCards);
};
}, [refreshDueCards]);
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
@ -61,6 +92,22 @@ export function Navbar() {
>
<span aria-hidden></span> Quizzes
</Link>
<Link
href={`/${classSlug}/spaced-repetition`}
onClick={() => setOpen(false)}
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/spaced-repetition") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden></span> Spaced Repetition
{classSlug && hasReadyCards && (
<span
className="ml-auto grid h-5 w-5 place-items-center rounded-full bg-error-bg text-xs font-extrabold text-error"
title="Spaced repetition cards are ready to study"
>
<span aria-hidden>!</span>
<span className="sr-only">Cards ready to study</span>
</span>
)}
</Link>
</div>
)}
</nav>

View file

@ -1,6 +1,7 @@
export const STUDY_MODES = [
{ key: "flashcards", label: "Flashcards", path: "flashcards" },
{ key: "quizzes", label: "Quizzes", path: "quizzes" },
{ key: "spaced-repetition", label: "Spaced Repetition", path: "spaced-repetition" },
// Future modes get added here — the layout, tab bar, and auth middleware need no changes.
] as const;

View file

@ -82,3 +82,18 @@ export type StudyActivity = Prisma.StudyActivityModel
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

View file

@ -106,3 +106,18 @@ export type StudyActivity = Prisma.StudyActivityModel
*
*/
export type MaterialGroup = Prisma.MaterialGroupModel
/**
* Model SpacedRepetitionSet
*
*/
export type SpacedRepetitionSet = Prisma.SpacedRepetitionSetModel
/**
* Model SpacedRepetitionSetDeck
*
*/
export type SpacedRepetitionSetDeck = Prisma.SpacedRepetitionSetDeckModel
/**
* Model SpacedRepetitionCardState
*
*/
export type SpacedRepetitionCardState = Prisma.SpacedRepetitionCardStateModel

File diff suppressed because one or more lines are too long

View file

@ -396,7 +396,10 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
MaterialGroup: 'MaterialGroup'
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -412,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup"
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup" | "spacedRepetitionSet" | "spacedRepetitionSetDeck" | "spacedRepetitionCardState"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@ -1378,6 +1381,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
SpacedRepetitionSet: {
payload: Prisma.$SpacedRepetitionSetPayload<ExtArgs>
fields: Prisma.SpacedRepetitionSetFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionSetFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionSetFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
findFirst: {
args: Prisma.SpacedRepetitionSetFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionSetFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
findMany: {
args: Prisma.SpacedRepetitionSetFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
create: {
args: Prisma.SpacedRepetitionSetCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
createMany: {
args: Prisma.SpacedRepetitionSetCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionSetCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
delete: {
args: Prisma.SpacedRepetitionSetDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
update: {
args: Prisma.SpacedRepetitionSetUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionSetDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionSetUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionSetUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionSetUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetPayload>
}
aggregate: {
args: Prisma.SpacedRepetitionSetAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSet>
}
groupBy: {
args: Prisma.SpacedRepetitionSetGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionSetCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetCountAggregateOutputType> | number
}
}
}
SpacedRepetitionSetDeck: {
payload: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>
fields: Prisma.SpacedRepetitionSetDeckFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionSetDeckFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionSetDeckFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
findFirst: {
args: Prisma.SpacedRepetitionSetDeckFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionSetDeckFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
findMany: {
args: Prisma.SpacedRepetitionSetDeckFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
create: {
args: Prisma.SpacedRepetitionSetDeckCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
createMany: {
args: Prisma.SpacedRepetitionSetDeckCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionSetDeckCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
delete: {
args: Prisma.SpacedRepetitionSetDeckDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
update: {
args: Prisma.SpacedRepetitionSetDeckUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionSetDeckDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionSetDeckUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionSetDeckUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionSetDeckUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionSetDeckPayload>
}
aggregate: {
args: Prisma.SpacedRepetitionSetDeckAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionSetDeck>
}
groupBy: {
args: Prisma.SpacedRepetitionSetDeckGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionSetDeckCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionSetDeckCountAggregateOutputType> | number
}
}
}
SpacedRepetitionCardState: {
payload: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>
fields: Prisma.SpacedRepetitionCardStateFieldRefs
operations: {
findUnique: {
args: Prisma.SpacedRepetitionCardStateFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
}
findUniqueOrThrow: {
args: Prisma.SpacedRepetitionCardStateFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
findFirst: {
args: Prisma.SpacedRepetitionCardStateFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload> | null
}
findFirstOrThrow: {
args: Prisma.SpacedRepetitionCardStateFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
findMany: {
args: Prisma.SpacedRepetitionCardStateFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
create: {
args: Prisma.SpacedRepetitionCardStateCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
createMany: {
args: Prisma.SpacedRepetitionCardStateCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SpacedRepetitionCardStateCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
delete: {
args: Prisma.SpacedRepetitionCardStateDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
update: {
args: Prisma.SpacedRepetitionCardStateUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
deleteMany: {
args: Prisma.SpacedRepetitionCardStateDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SpacedRepetitionCardStateUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SpacedRepetitionCardStateUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>[]
}
upsert: {
args: Prisma.SpacedRepetitionCardStateUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SpacedRepetitionCardStatePayload>
}
aggregate: {
args: Prisma.SpacedRepetitionCardStateAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSpacedRepetitionCardState>
}
groupBy: {
args: Prisma.SpacedRepetitionCardStateGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateGroupByOutputType>[]
}
count: {
args: Prisma.SpacedRepetitionCardStateCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SpacedRepetitionCardStateCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@ -1566,6 +1791,52 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@ -1745,6 +2016,9 @@ export type GlobalOmitConfig = {
setting?: Prisma.SettingOmit
studyActivity?: Prisma.StudyActivityOmit
materialGroup?: Prisma.MaterialGroupOmit
spacedRepetitionSet?: Prisma.SpacedRepetitionSetOmit
spacedRepetitionSetDeck?: Prisma.SpacedRepetitionSetDeckOmit
spacedRepetitionCardState?: Prisma.SpacedRepetitionCardStateOmit
}
/* Types for Logging */

View file

@ -63,7 +63,10 @@ export const ModelName = {
AuthSecurity: 'AuthSecurity',
Setting: 'Setting',
StudyActivity: 'StudyActivity',
MaterialGroup: 'MaterialGroup'
MaterialGroup: 'MaterialGroup',
SpacedRepetitionSet: 'SpacedRepetitionSet',
SpacedRepetitionSetDeck: 'SpacedRepetitionSetDeck',
SpacedRepetitionCardState: 'SpacedRepetitionCardState'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@ -231,6 +234,52 @@ export const MaterialGroupScalarFieldEnum = {
export type MaterialGroupScalarFieldEnum = (typeof MaterialGroupScalarFieldEnum)[keyof typeof MaterialGroupScalarFieldEnum]
export const SpacedRepetitionSetScalarFieldEnum = {
id: 'id',
classId: 'classId',
name: 'name',
description: 'description',
newCardsPerDay: 'newCardsPerDay',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionSetScalarFieldEnum = (typeof SpacedRepetitionSetScalarFieldEnum)[keyof typeof SpacedRepetitionSetScalarFieldEnum]
export const SpacedRepetitionSetDeckScalarFieldEnum = {
setId: 'setId',
deckId: 'deckId',
sortOrder: 'sortOrder',
addedAt: 'addedAt'
} as const
export type SpacedRepetitionSetDeckScalarFieldEnum = (typeof SpacedRepetitionSetDeckScalarFieldEnum)[keyof typeof SpacedRepetitionSetDeckScalarFieldEnum]
export const SpacedRepetitionCardStateScalarFieldEnum = {
id: 'id',
setId: 'setId',
flashcardId: 'flashcardId',
due: 'due',
stability: 'stability',
difficulty: 'difficulty',
elapsedDays: 'elapsedDays',
scheduledDays: 'scheduledDays',
learningSteps: 'learningSteps',
reps: 'reps',
lapses: 'lapses',
state: 'state',
firstReviewedAt: 'firstReviewedAt',
lastReview: 'lastReview',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SpacedRepetitionCardStateScalarFieldEnum = (typeof SpacedRepetitionCardStateScalarFieldEnum)[keyof typeof SpacedRepetitionCardStateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'

View file

@ -21,4 +21,7 @@ export type * from './models/AuthSecurity'
export type * from './models/Setting'
export type * from './models/StudyActivity'
export type * from './models/MaterialGroup'
export type * from './models/SpacedRepetitionSet'
export type * from './models/SpacedRepetitionSetDeck'
export type * from './models/SpacedRepetitionCardState'
export type * from './commonInputTypes'

View file

@ -219,6 +219,7 @@ export type ClassWhereInput = {
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
}
export type ClassOrderByWithRelationInput = {
@ -230,6 +231,7 @@ export type ClassOrderByWithRelationInput = {
decks?: Prisma.DeckOrderByRelationAggregateInput
quizSets?: Prisma.QuizSetOrderByRelationAggregateInput
materialGroups?: Prisma.MaterialGroupOrderByRelationAggregateInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetOrderByRelationAggregateInput
}
export type ClassWhereUniqueInput = Prisma.AtLeast<{
@ -244,6 +246,7 @@ export type ClassWhereUniqueInput = Prisma.AtLeast<{
decks?: Prisma.DeckListRelationFilter
quizSets?: Prisma.QuizSetListRelationFilter
materialGroups?: Prisma.MaterialGroupListRelationFilter
spacedRepetitionSets?: Prisma.SpacedRepetitionSetListRelationFilter
}, "id" | "slug">
export type ClassOrderByWithAggregationInput = {
@ -279,6 +282,7 @@ export type ClassCreateInput = {
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateInput = {
@ -290,6 +294,7 @@ export type ClassUncheckedCreateInput = {
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassUpdateInput = {
@ -301,6 +306,7 @@ export type ClassUpdateInput = {
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateInput = {
@ -312,6 +318,7 @@ export type ClassUncheckedUpdateInput = {
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateManyInput = {
@ -433,6 +440,20 @@ export type ClassUpdateOneRequiredWithoutMaterialGroupsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutMaterialGroupsInput, Prisma.ClassUpdateWithoutMaterialGroupsInput>, Prisma.ClassUncheckedUpdateWithoutMaterialGroupsInput>
}
export type ClassCreateNestedOneWithoutSpacedRepetitionSetsInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
}
export type ClassUpdateOneRequiredWithoutSpacedRepetitionSetsNestedInput = {
create?: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
connectOrCreate?: Prisma.ClassCreateOrConnectWithoutSpacedRepetitionSetsInput
upsert?: Prisma.ClassUpsertWithoutSpacedRepetitionSetsInput
connect?: Prisma.ClassWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput, Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput>, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassCreateWithoutDecksInput = {
id?: string
slug: string
@ -441,6 +462,7 @@ export type ClassCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutDecksInput = {
@ -451,6 +473,7 @@ export type ClassUncheckedCreateWithoutDecksInput = {
createdAt?: Date | string
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutDecksInput = {
@ -477,6 +500,7 @@ export type ClassUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutDecksInput = {
@ -487,6 +511,7 @@ export type ClassUncheckedUpdateWithoutDecksInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutQuizSetsInput = {
@ -497,6 +522,7 @@ export type ClassCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutQuizSetsInput = {
@ -507,6 +533,7 @@ export type ClassUncheckedCreateWithoutQuizSetsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutQuizSetsInput = {
@ -533,6 +560,7 @@ export type ClassUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutQuizSetsInput = {
@ -543,6 +571,7 @@ export type ClassUncheckedUpdateWithoutQuizSetsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutMaterialGroupsInput = {
@ -553,6 +582,7 @@ export type ClassCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
@ -563,6 +593,7 @@ export type ClassUncheckedCreateWithoutMaterialGroupsInput = {
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutMaterialGroupsInput = {
@ -589,6 +620,7 @@ export type ClassUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
@ -599,6 +631,67 @@ export type ClassUncheckedUpdateWithoutMaterialGroupsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
spacedRepetitionSets?: Prisma.SpacedRepetitionSetUncheckedUpdateManyWithoutClassNestedInput
}
export type ClassCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupCreateNestedManyWithoutClassInput
}
export type ClassUncheckedCreateWithoutSpacedRepetitionSetsInput = {
id?: string
slug: string
name: string
sortOrder?: number
createdAt?: Date | string
decks?: Prisma.DeckUncheckedCreateNestedManyWithoutClassInput
quizSets?: Prisma.QuizSetUncheckedCreateNestedManyWithoutClassInput
materialGroups?: Prisma.MaterialGroupUncheckedCreateNestedManyWithoutClassInput
}
export type ClassCreateOrConnectWithoutSpacedRepetitionSetsInput = {
where: Prisma.ClassWhereUniqueInput
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpsertWithoutSpacedRepetitionSetsInput = {
update: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
create: Prisma.XOR<Prisma.ClassCreateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedCreateWithoutSpacedRepetitionSetsInput>
where?: Prisma.ClassWhereInput
}
export type ClassUpdateToOneWithWhereWithoutSpacedRepetitionSetsInput = {
where?: Prisma.ClassWhereInput
data: Prisma.XOR<Prisma.ClassUpdateWithoutSpacedRepetitionSetsInput, Prisma.ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput>
}
export type ClassUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUpdateManyWithoutClassNestedInput
}
export type ClassUncheckedUpdateWithoutSpacedRepetitionSetsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
slug?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
decks?: Prisma.DeckUncheckedUpdateManyWithoutClassNestedInput
quizSets?: Prisma.QuizSetUncheckedUpdateManyWithoutClassNestedInput
materialGroups?: Prisma.MaterialGroupUncheckedUpdateManyWithoutClassNestedInput
}
@ -610,12 +703,14 @@ export type ClassCountOutputType = {
decks: number
quizSets: number
materialGroups: number
spacedRepetitionSets: number
}
export type ClassCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
decks?: boolean | ClassCountOutputTypeCountDecksArgs
quizSets?: boolean | ClassCountOutputTypeCountQuizSetsArgs
materialGroups?: boolean | ClassCountOutputTypeCountMaterialGroupsArgs
spacedRepetitionSets?: boolean | ClassCountOutputTypeCountSpacedRepetitionSetsArgs
}
/**
@ -649,6 +744,13 @@ export type ClassCountOutputTypeCountMaterialGroupsArgs<ExtArgs extends runtime.
where?: Prisma.MaterialGroupWhereInput
}
/**
* ClassCountOutputType without action
*/
export type ClassCountOutputTypeCountSpacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetWhereInput
}
export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -659,6 +761,7 @@ export type ClassSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["class"]>
@ -691,6 +794,7 @@ export type ClassInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
decks?: boolean | Prisma.Class$decksArgs<ExtArgs>
quizSets?: boolean | Prisma.Class$quizSetsArgs<ExtArgs>
materialGroups?: boolean | Prisma.Class$materialGroupsArgs<ExtArgs>
spacedRepetitionSets?: boolean | Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>
_count?: boolean | Prisma.ClassCountOutputTypeDefaultArgs<ExtArgs>
}
export type ClassIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@ -702,6 +806,7 @@ export type $ClassPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
decks: Prisma.$DeckPayload<ExtArgs>[]
quizSets: Prisma.$QuizSetPayload<ExtArgs>[]
materialGroups: Prisma.$MaterialGroupPayload<ExtArgs>[]
spacedRepetitionSets: Prisma.$SpacedRepetitionSetPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1106,6 +1211,7 @@ export interface Prisma__ClassClient<T, Null = never, ExtArgs extends runtime.Ty
decks<T extends Prisma.Class$decksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$decksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
quizSets<T extends Prisma.Class$quizSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$quizSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$QuizSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
materialGroups<T extends Prisma.Class$materialGroupsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$materialGroupsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$MaterialGroupPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionSets<T extends Prisma.Class$spacedRepetitionSetsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Class$spacedRepetitionSetsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1602,6 +1708,30 @@ export type Class$materialGroupsArgs<ExtArgs extends runtime.Types.Extensions.In
distinct?: Prisma.MaterialGroupScalarFieldEnum | Prisma.MaterialGroupScalarFieldEnum[]
}
/**
* Class.spacedRepetitionSets
*/
export type Class$spacedRepetitionSetsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSet
*/
select?: Prisma.SpacedRepetitionSetSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSet
*/
omit?: Prisma.SpacedRepetitionSetOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetWhereInput
orderBy?: Prisma.SpacedRepetitionSetOrderByWithRelationInput | Prisma.SpacedRepetitionSetOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetScalarFieldEnum | Prisma.SpacedRepetitionSetScalarFieldEnum[]
}
/**
* Class without action
*/

View file

@ -237,6 +237,7 @@ export type DeckWhereInput = {
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}
export type DeckOrderByWithRelationInput = {
@ -252,6 +253,7 @@ export type DeckOrderByWithRelationInput = {
cards?: Prisma.FlashcardOrderByRelationAggregateInput
shareLink?: Prisma.ShareLinkOrderByWithRelationInput
progress?: Prisma.StudyProgressOrderByRelationAggregateInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckOrderByRelationAggregateInput
}
export type DeckWhereUniqueInput = Prisma.AtLeast<{
@ -270,6 +272,7 @@ export type DeckWhereUniqueInput = Prisma.AtLeast<{
cards?: Prisma.FlashcardListRelationFilter
shareLink?: Prisma.XOR<Prisma.ShareLinkNullableScalarRelationFilter, Prisma.ShareLinkWhereInput> | null
progress?: Prisma.StudyProgressListRelationFilter
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckListRelationFilter
}, "id">
export type DeckOrderByWithAggregationInput = {
@ -311,6 +314,7 @@ export type DeckCreateInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateInput = {
@ -324,6 +328,7 @@ export type DeckUncheckedCreateInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckUpdateInput = {
@ -337,6 +342,7 @@ export type DeckUpdateInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateInput = {
@ -350,6 +356,7 @@ export type DeckUncheckedUpdateInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyInput = {
@ -572,6 +579,20 @@ export type DeckUncheckedUpdateManyWithoutGroupNestedInput = {
deleteMany?: Prisma.DeckScalarWhereInput | Prisma.DeckScalarWhereInput[]
}
export type DeckCreateNestedOneWithoutSpacedRepetitionMembershipsInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
}
export type DeckUpdateOneRequiredWithoutSpacedRepetitionMembershipsNestedInput = {
create?: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
connectOrCreate?: Prisma.DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput
upsert?: Prisma.DeckUpsertWithoutSpacedRepetitionMembershipsInput
connect?: Prisma.DeckWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput>, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckCreateWithoutClassInput = {
id?: string
name: string
@ -582,6 +603,7 @@ export type DeckCreateWithoutClassInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutClassInput = {
@ -594,6 +616,7 @@ export type DeckUncheckedCreateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutClassInput = {
@ -644,6 +667,7 @@ export type DeckCreateWithoutCardsInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutCardsInput = {
@ -656,6 +680,7 @@ export type DeckUncheckedCreateWithoutCardsInput = {
groupId?: string | null
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutCardsInput = {
@ -684,6 +709,7 @@ export type DeckUpdateWithoutCardsInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutCardsInput = {
@ -696,6 +722,7 @@ export type DeckUncheckedUpdateWithoutCardsInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutProgressInput = {
@ -708,6 +735,7 @@ export type DeckCreateWithoutProgressInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutProgressInput = {
@ -720,6 +748,7 @@ export type DeckUncheckedCreateWithoutProgressInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutProgressInput = {
@ -748,6 +777,7 @@ export type DeckUpdateWithoutProgressInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutProgressInput = {
@ -760,6 +790,7 @@ export type DeckUncheckedUpdateWithoutProgressInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutShareLinkInput = {
@ -772,6 +803,7 @@ export type DeckCreateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutShareLinkInput = {
@ -784,6 +816,7 @@ export type DeckUncheckedCreateWithoutShareLinkInput = {
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutShareLinkInput = {
@ -812,6 +845,7 @@ export type DeckUpdateWithoutShareLinkInput = {
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutShareLinkInput = {
@ -824,6 +858,7 @@ export type DeckUncheckedUpdateWithoutShareLinkInput = {
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateWithoutGroupInput = {
@ -836,6 +871,7 @@ export type DeckCreateWithoutGroupInput = {
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutGroupInput = {
@ -848,6 +884,7 @@ export type DeckUncheckedCreateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutGroupInput = {
@ -875,6 +912,74 @@ export type DeckUpdateManyWithWhereWithoutGroupInput = {
data: Prisma.XOR<Prisma.DeckUpdateManyMutationInput, Prisma.DeckUncheckedUpdateManyWithoutGroupInput>
}
export type DeckCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
class: Prisma.ClassCreateNestedOneWithoutDecksInput
group?: Prisma.MaterialGroupCreateNestedOneWithoutDecksInput
cards?: Prisma.FlashcardCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressCreateNestedManyWithoutDeckInput
}
export type DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput = {
id?: string
classId: string
name: string
description?: string | null
sortOrder?: number
createdAt?: Date | string
groupId?: string | null
cards?: Prisma.FlashcardUncheckedCreateNestedManyWithoutDeckInput
shareLink?: Prisma.ShareLinkUncheckedCreateNestedOneWithoutDeckInput
progress?: Prisma.StudyProgressUncheckedCreateNestedManyWithoutDeckInput
}
export type DeckCreateOrConnectWithoutSpacedRepetitionMembershipsInput = {
where: Prisma.DeckWhereUniqueInput
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpsertWithoutSpacedRepetitionMembershipsInput = {
update: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
create: Prisma.XOR<Prisma.DeckCreateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedCreateWithoutSpacedRepetitionMembershipsInput>
where?: Prisma.DeckWhereInput
}
export type DeckUpdateToOneWithWhereWithoutSpacedRepetitionMembershipsInput = {
where?: Prisma.DeckWhereInput
data: Prisma.XOR<Prisma.DeckUpdateWithoutSpacedRepetitionMembershipsInput, Prisma.DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput>
}
export type DeckUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
class?: Prisma.ClassUpdateOneRequiredWithoutDecksNestedInput
group?: Prisma.MaterialGroupUpdateOneWithoutDecksNestedInput
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutSpacedRepetitionMembershipsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
classId?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckCreateManyClassInput = {
id?: string
name: string
@ -894,6 +999,7 @@ export type DeckUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutClassInput = {
@ -906,6 +1012,7 @@ export type DeckUncheckedUpdateWithoutClassInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutClassInput = {
@ -936,6 +1043,7 @@ export type DeckUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateWithoutGroupInput = {
@ -948,6 +1056,7 @@ export type DeckUncheckedUpdateWithoutGroupInput = {
cards?: Prisma.FlashcardUncheckedUpdateManyWithoutDeckNestedInput
shareLink?: Prisma.ShareLinkUncheckedUpdateOneWithoutDeckNestedInput
progress?: Prisma.StudyProgressUncheckedUpdateManyWithoutDeckNestedInput
spacedRepetitionMemberships?: Prisma.SpacedRepetitionSetDeckUncheckedUpdateManyWithoutDeckNestedInput
}
export type DeckUncheckedUpdateManyWithoutGroupInput = {
@ -967,11 +1076,13 @@ export type DeckUncheckedUpdateManyWithoutGroupInput = {
export type DeckCountOutputType = {
cards: number
progress: number
spacedRepetitionMemberships: number
}
export type DeckCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
cards?: boolean | DeckCountOutputTypeCountCardsArgs
progress?: boolean | DeckCountOutputTypeCountProgressArgs
spacedRepetitionMemberships?: boolean | DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs
}
/**
@ -998,6 +1109,13 @@ export type DeckCountOutputTypeCountProgressArgs<ExtArgs extends runtime.Types.E
where?: Prisma.StudyProgressWhereInput
}
/**
* DeckCountOutputType without action
*/
export type DeckCountOutputTypeCountSpacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionSetDeckWhereInput
}
export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -1012,6 +1130,7 @@ export type DeckSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["deck"]>
@ -1056,6 +1175,7 @@ export type DeckInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards?: boolean | Prisma.Deck$cardsArgs<ExtArgs>
shareLink?: boolean | Prisma.Deck$shareLinkArgs<ExtArgs>
progress?: boolean | Prisma.Deck$progressArgs<ExtArgs>
spacedRepetitionMemberships?: boolean | Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>
_count?: boolean | Prisma.DeckCountOutputTypeDefaultArgs<ExtArgs>
}
export type DeckIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@ -1075,6 +1195,7 @@ export type $DeckPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
cards: Prisma.$FlashcardPayload<ExtArgs>[]
shareLink: Prisma.$ShareLinkPayload<ExtArgs> | null
progress: Prisma.$StudyProgressPayload<ExtArgs>[]
spacedRepetitionMemberships: Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -1483,6 +1604,7 @@ export interface Prisma__DeckClient<T, Null = never, ExtArgs extends runtime.Typ
cards<T extends Prisma.Deck$cardsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$cardsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$FlashcardPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
shareLink<T extends Prisma.Deck$shareLinkArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$shareLinkArgs<ExtArgs>>): Prisma.Prisma__ShareLinkClient<runtime.Types.Result.GetResult<Prisma.$ShareLinkPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
progress<T extends Prisma.Deck$progressArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$progressArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$StudyProgressPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
spacedRepetitionMemberships<T extends Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Deck$spacedRepetitionMembershipsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionSetDeckPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -2003,6 +2125,30 @@ export type Deck$progressArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.StudyProgressScalarFieldEnum | Prisma.StudyProgressScalarFieldEnum[]
}
/**
* Deck.spacedRepetitionMemberships
*/
export type Deck$spacedRepetitionMembershipsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionSetDeck
*/
select?: Prisma.SpacedRepetitionSetDeckSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionSetDeck
*/
omit?: Prisma.SpacedRepetitionSetDeckOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionSetDeckInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionSetDeckWhereInput
orderBy?: Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput | Prisma.SpacedRepetitionSetDeckOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionSetDeckWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionSetDeckScalarFieldEnum | Prisma.SpacedRepetitionSetDeckScalarFieldEnum[]
}
/**
* Deck without action
*/

View file

@ -217,6 +217,7 @@ export type FlashcardWhereInput = {
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}
export type FlashcardOrderByWithRelationInput = {
@ -226,6 +227,7 @@ export type FlashcardOrderByWithRelationInput = {
back?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
deck?: Prisma.DeckOrderByWithRelationInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateOrderByRelationAggregateInput
}
export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
@ -238,6 +240,7 @@ export type FlashcardWhereUniqueInput = Prisma.AtLeast<{
back?: Prisma.StringFilter<"Flashcard"> | string
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
deck?: Prisma.XOR<Prisma.DeckScalarRelationFilter, Prisma.DeckWhereInput>
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateListRelationFilter
}, "id">
export type FlashcardOrderByWithAggregationInput = {
@ -270,6 +273,7 @@ export type FlashcardCreateInput = {
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateInput = {
@ -278,6 +282,7 @@ export type FlashcardUncheckedCreateInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUpdateInput = {
@ -286,6 +291,7 @@ export type FlashcardUpdateInput = {
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateInput = {
@ -294,6 +300,7 @@ export type FlashcardUncheckedUpdateInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardCreateManyInput = {
@ -361,6 +368,11 @@ export type FlashcardSumOrderByAggregateInput = {
sortOrder?: Prisma.SortOrder
}
export type FlashcardScalarRelationFilter = {
is?: Prisma.FlashcardWhereInput
isNot?: Prisma.FlashcardWhereInput
}
export type FlashcardCreateNestedManyWithoutDeckInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutDeckInput, Prisma.FlashcardUncheckedCreateWithoutDeckInput> | Prisma.FlashcardCreateWithoutDeckInput[] | Prisma.FlashcardUncheckedCreateWithoutDeckInput[]
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutDeckInput | Prisma.FlashcardCreateOrConnectWithoutDeckInput[]
@ -403,11 +415,26 @@ export type FlashcardUncheckedUpdateManyWithoutDeckNestedInput = {
deleteMany?: Prisma.FlashcardScalarWhereInput | Prisma.FlashcardScalarWhereInput[]
}
export type FlashcardCreateNestedOneWithoutSpacedRepetitionStatesInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
}
export type FlashcardUpdateOneRequiredWithoutSpacedRepetitionStatesNestedInput = {
create?: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
connectOrCreate?: Prisma.FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput
upsert?: Prisma.FlashcardUpsertWithoutSpacedRepetitionStatesInput
connect?: Prisma.FlashcardWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput>, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardCreateWithoutDeckInput = {
id?: string
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateCreateNestedManyWithoutFlashcardInput
}
export type FlashcardUncheckedCreateWithoutDeckInput = {
@ -415,6 +442,7 @@ export type FlashcardUncheckedCreateWithoutDeckInput = {
front: string
back: string
sortOrder?: number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedCreateNestedManyWithoutFlashcardInput
}
export type FlashcardCreateOrConnectWithoutDeckInput = {
@ -453,6 +481,54 @@ export type FlashcardScalarWhereInput = {
sortOrder?: Prisma.IntFilter<"Flashcard"> | number
}
export type FlashcardCreateWithoutSpacedRepetitionStatesInput = {
id?: string
front: string
back: string
sortOrder?: number
deck: Prisma.DeckCreateNestedOneWithoutCardsInput
}
export type FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput = {
id?: string
deckId: string
front: string
back: string
sortOrder?: number
}
export type FlashcardCreateOrConnectWithoutSpacedRepetitionStatesInput = {
where: Prisma.FlashcardWhereUniqueInput
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpsertWithoutSpacedRepetitionStatesInput = {
update: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
create: Prisma.XOR<Prisma.FlashcardCreateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedCreateWithoutSpacedRepetitionStatesInput>
where?: Prisma.FlashcardWhereInput
}
export type FlashcardUpdateToOneWithWhereWithoutSpacedRepetitionStatesInput = {
where?: Prisma.FlashcardWhereInput
data: Prisma.XOR<Prisma.FlashcardUpdateWithoutSpacedRepetitionStatesInput, Prisma.FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput>
}
export type FlashcardUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
deck?: Prisma.DeckUpdateOneRequiredWithoutCardsNestedInput
}
export type FlashcardUncheckedUpdateWithoutSpacedRepetitionStatesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deckId?: Prisma.StringFieldUpdateOperationsInput | string
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
}
export type FlashcardCreateManyDeckInput = {
id?: string
front: string
@ -465,6 +541,7 @@ export type FlashcardUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateWithoutDeckInput = {
@ -472,6 +549,7 @@ export type FlashcardUncheckedUpdateWithoutDeckInput = {
front?: Prisma.StringFieldUpdateOperationsInput | string
back?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
spacedRepetitionStates?: Prisma.SpacedRepetitionCardStateUncheckedUpdateManyWithoutFlashcardNestedInput
}
export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
@ -482,6 +560,35 @@ export type FlashcardUncheckedUpdateManyWithoutDeckInput = {
}
/**
* Count Type FlashcardCountOutputType
*/
export type FlashcardCountOutputType = {
spacedRepetitionStates: number
}
export type FlashcardCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
spacedRepetitionStates?: boolean | FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs
}
/**
* FlashcardCountOutputType without action
*/
export type FlashcardCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the FlashcardCountOutputType
*/
select?: Prisma.FlashcardCountOutputTypeSelect<ExtArgs> | null
}
/**
* FlashcardCountOutputType without action
*/
export type FlashcardCountOutputTypeCountSpacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.SpacedRepetitionCardStateWhereInput
}
export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@ -490,6 +597,8 @@ export type FlashcardSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
back?: boolean
sortOrder?: boolean
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["flashcard"]>
export type FlashcardSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@ -521,6 +630,8 @@ export type FlashcardSelectScalar = {
export type FlashcardOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "deckId" | "front" | "back" | "sortOrder", ExtArgs["result"]["flashcard"]>
export type FlashcardInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
spacedRepetitionStates?: boolean | Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>
_count?: boolean | Prisma.FlashcardCountOutputTypeDefaultArgs<ExtArgs>
}
export type FlashcardIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.DeckDefaultArgs<ExtArgs>
@ -533,6 +644,7 @@ export type $FlashcardPayload<ExtArgs extends runtime.Types.Extensions.InternalA
name: "Flashcard"
objects: {
deck: Prisma.$DeckPayload<ExtArgs>
spacedRepetitionStates: Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@ -935,6 +1047,7 @@ readonly fields: FlashcardFieldRefs;
export interface Prisma__FlashcardClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
deck<T extends Prisma.DeckDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeckDefaultArgs<ExtArgs>>): Prisma.Prisma__DeckClient<runtime.Types.Result.GetResult<Prisma.$DeckPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
spacedRepetitionStates<T extends Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Flashcard$spacedRepetitionStatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SpacedRepetitionCardStatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1367,6 +1480,30 @@ export type FlashcardDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Int
limit?: number
}
/**
* Flashcard.spacedRepetitionStates
*/
export type Flashcard$spacedRepetitionStatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the SpacedRepetitionCardState
*/
select?: Prisma.SpacedRepetitionCardStateSelect<ExtArgs> | null
/**
* Omit specific fields from the SpacedRepetitionCardState
*/
omit?: Prisma.SpacedRepetitionCardStateOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.SpacedRepetitionCardStateInclude<ExtArgs> | null
where?: Prisma.SpacedRepetitionCardStateWhereInput
orderBy?: Prisma.SpacedRepetitionCardStateOrderByWithRelationInput | Prisma.SpacedRepetitionCardStateOrderByWithRelationInput[]
cursor?: Prisma.SpacedRepetitionCardStateWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.SpacedRepetitionCardStateScalarFieldEnum | Prisma.SpacedRepetitionCardStateScalarFieldEnum[]
}
/**
* Flashcard without action
*/

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
calculateQueueCounts,
getArizonaDayBounds,
getEffectiveDue,
getLearnAheadCutoff,
previewRatings,
scheduleRating,
type SpacedRepetitionRating,
} from "@/lib/spacedRepetition";
describe("spaced repetition scheduling", () => {
const now = new Date("2026-07-13T20:00:00.000Z");
it("returns a distinct valid state for every rating", () => {
const ratings: SpacedRepetitionRating[] = ["AGAIN", "HARD", "GOOD", "EASY"];
for (const rating of ratings) {
const state = scheduleRating(null, rating, now);
expect(state.reps).toBe(1);
expect(state.lastReview?.toISOString()).toBe(now.toISOString());
expect(state.due.getTime()).toBeGreaterThan(now.getTime());
}
});
it("orders first-review previews from Again through Easy", () => {
const previews = previewRatings(null, now);
const dueTimes = previews.map((preview) => new Date(preview.due).getTime());
expect(dueTimes).toHaveLength(4);
expect(dueTimes[0]).toBeLessThan(dueTimes[1]);
expect(dueTimes[1]).toBeLessThanOrEqual(dueTimes[2]);
expect(dueTimes[2]).toBeLessThan(dueTimes[3]);
expect(dueTimes.map((due) => Math.round((due - now.getTime()) / 60000))).toEqual([
1,
6,
10,
7 * 24 * 60 + 11 * 60,
]);
expect(new Date(previews[3].due).toISOString()).toBe(
"2026-07-21T07:00:00.000Z"
);
expect(previews[3].scheduledDays).toBe(8);
});
it("aligns graduated day intervals to Arizona midnight", () => {
const learning = scheduleRating(null, "GOOD", now);
const graduated = scheduleRating(learning, "GOOD", learning.due);
expect(graduated.scheduledDays).toBeGreaterThanOrEqual(1);
expect(graduated.due.toISOString()).toMatch(/T07:00:00\.000Z$/);
});
it("uses Anki's 20-minute learn-ahead window", () => {
expect(getLearnAheadCutoff(now).toISOString()).toBe("2026-07-13T20:20:00.000Z");
});
it("uses midnight in America/Phoenix as the daily boundary", () => {
const beforeMidnight = getArizonaDayBounds(new Date("2026-07-14T06:59:59.000Z"));
const afterMidnight = getArizonaDayBounds(new Date("2026-07-14T07:00:00.000Z"));
expect(beforeMidnight.start.toISOString()).toBe("2026-07-13T07:00:00.000Z");
expect(afterMidnight.start.toISOString()).toBe("2026-07-14T07:00:00.000Z");
});
it("treats legacy day-based due times as due at midnight", () => {
expect(getEffectiveDue({
due: new Date("2026-07-14T20:00:00.000Z"),
scheduledDays: 1,
}).toISOString()).toBe("2026-07-14T07:00:00.000Z");
expect(getEffectiveDue({
due: new Date("2026-07-14T07:10:00.000Z"),
scheduledDays: 0,
}).toISOString()).toBe("2026-07-14T07:10:00.000Z");
});
});
describe("daily queue counts", () => {
it("adds all due cards while capping only new cards", () => {
expect(calculateQueueCounts({
totalCards: 100,
reviewedCards: 40,
dueCards: 55,
introducedToday: 12,
newCardsPerDay: 30,
})).toEqual({ unseenCards: 60, newCardsAvailable: 18, learningAheadCards: 0, availableCards: 73 });
});
it("can pause new cards without hiding due reviews", () => {
expect(calculateQueueCounts({
totalCards: 100,
reviewedCards: 40,
dueCards: 7,
introducedToday: 0,
newCardsPerDay: 0,
})).toEqual({ unseenCards: 60, newCardsAvailable: 0, learningAheadCards: 0, availableCards: 7 });
});
it("never offers more new cards than remain unseen", () => {
expect(calculateQueueCounts({
totalCards: 12,
reviewedCards: 10,
dueCards: 0,
introducedToday: 0,
newCardsPerDay: 30,
}).newCardsAvailable).toBe(2);
});
it("keeps short learning steps available when no new cards remain", () => {
expect(calculateQueueCounts({
totalCards: 25,
reviewedCards: 25,
dueCards: 0,
learningAheadCards: 25,
introducedToday: 25,
newCardsPerDay: 30,
})).toEqual({
unseenCards: 0,
newCardsAvailable: 0,
learningAheadCards: 25,
availableCards: 25,
});
});
});

162
src/lib/spacedRepetition.ts Normal file
View file

@ -0,0 +1,162 @@
import {
createEmptyCard,
fsrs,
Rating,
State,
type Card,
type Grade,
} from "ts-fsrs";
export const SPACED_REPETITION_RATINGS = [
"AGAIN",
"HARD",
"GOOD",
"EASY",
] as const;
export type SpacedRepetitionRating =
(typeof SPACED_REPETITION_RATINGS)[number];
export interface StoredScheduleState {
due: Date;
stability: number;
difficulty: number;
elapsedDays: number;
scheduledDays: number;
learningSteps: number;
reps: number;
lapses: number;
state: number;
lastReview: Date | null;
}
export interface RatingPreview {
rating: SpacedRepetitionRating;
due: string;
scheduledDays: number;
}
export const LEARN_AHEAD_LIMIT_MINUTES = 20;
export const INTRADAY_LEARNING_STATES: number[] = [
State.Learning,
State.Relearning,
];
export function getLearnAheadCutoff(now: Date) {
return new Date(now.getTime() + LEARN_AHEAD_LIMIT_MINUTES * 60 * 1000);
}
export function getEffectiveDue(state: { due: Date; scheduledDays: number }) {
return state.scheduledDays >= 1
? getArizonaDayBounds(state.due).start
: state.due;
}
export function calculateQueueCounts(input: {
totalCards: number;
reviewedCards: number;
dueCards: number;
learningAheadCards?: number;
introducedToday: number;
newCardsPerDay: number;
}) {
const unseenCards = Math.max(0, input.totalCards - input.reviewedCards);
const newCardsAvailable = Math.min(
unseenCards,
Math.max(0, input.newCardsPerDay - input.introducedToday)
);
return {
unseenCards,
newCardsAvailable,
learningAheadCards: input.learningAheadCards ?? 0,
availableCards:
input.dueCards + newCardsAvailable + (input.learningAheadCards ?? 0),
};
}
const scheduler = fsrs({ request_retention: 0.9, enable_fuzz: false });
const ratingMap: Record<SpacedRepetitionRating, Grade> = {
AGAIN: Rating.Again,
HARD: Rating.Hard,
GOOD: Rating.Good,
EASY: Rating.Easy,
};
function toCard(state: StoredScheduleState | null, now: Date): Card {
if (!state) return createEmptyCard(now);
return {
due: state.due,
stability: state.stability,
difficulty: state.difficulty,
elapsed_days: state.elapsedDays,
scheduled_days: state.scheduledDays,
learning_steps: state.learningSteps,
reps: state.reps,
lapses: state.lapses,
state: state.state as State,
last_review: state.lastReview ?? undefined,
};
}
function fromCard(card: Card): StoredScheduleState {
return {
due: card.due,
stability: card.stability,
difficulty: card.difficulty,
elapsedDays: card.elapsed_days,
scheduledDays: card.scheduled_days,
learningSteps: card.learning_steps,
reps: card.reps,
lapses: card.lapses,
state: card.state,
lastReview: card.last_review ?? null,
};
}
function alignDayIntervalToArizonaMidnight(card: Card, now: Date): Card {
if (card.scheduled_days < 1) return card;
const { start } = getArizonaDayBounds(now);
return {
...card,
due: new Date(
start.getTime() + card.scheduled_days * 24 * 60 * 60 * 1000
),
};
}
export function previewRatings(
state: StoredScheduleState | null,
now: Date
): RatingPreview[] {
const preview = scheduler.repeat(toCard(state, now), now);
return SPACED_REPETITION_RATINGS.map((rating) => {
const card = alignDayIntervalToArizonaMidnight(
preview[ratingMap[rating]].card,
now
);
return {
rating,
due: card.due.toISOString(),
scheduledDays: card.scheduled_days,
};
});
}
export function scheduleRating(
state: StoredScheduleState | null,
rating: SpacedRepetitionRating,
now: Date
): StoredScheduleState {
const next = scheduler.next(toCard(state, now), now, ratingMap[rating]).card;
return fromCard(alignDayIntervalToArizonaMidnight(next, now));
}
export function getArizonaDayBounds(now: Date): { start: Date; end: Date } {
const arizonaNow = new Date(now.getTime() - 7 * 60 * 60 * 1000);
const dateKey = arizonaNow.toISOString().slice(0, 10);
const start = new Date(`${dateKey}T07:00:00.000Z`);
return { start, end: new Date(start.getTime() + 24 * 60 * 60 * 1000) };
}

View file

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { SpacedRepetitionServiceError } from "@/services/spacedRepetitionService";
export function spacedRepetitionErrorResponse(error: unknown) {
if (error instanceof SpacedRepetitionServiceError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
return NextResponse.json({ error: "Spaced repetition request failed" }, { status: 500 });
}

View file

@ -0,0 +1,44 @@
import { z } from "zod";
import { SPACED_REPETITION_RATINGS } from "@/lib/spacedRepetition";
const nameSchema = z.string().trim().min(1).max(120);
const descriptionSchema = z.string().trim().max(5000).nullable().optional();
const newCardsPerDaySchema = z.number().int().min(0).max(999);
export const createSpacedRepetitionSetSchema = z.object({
classId: z.string().min(1),
name: nameSchema,
description: descriptionSchema,
newCardsPerDay: newCardsPerDaySchema.default(30),
});
export const updateSpacedRepetitionSetSchema = z
.object({
name: nameSchema.optional(),
description: descriptionSchema,
newCardsPerDay: newCardsPerDaySchema.optional(),
})
.refine((value) => Object.values(value).some((item) => item !== undefined), {
message: "At least one field is required",
});
export const addSpacedRepetitionDeckSchema = z.object({
deckId: z.string().min(1),
});
export const reorderSpacedRepetitionDecksSchema = z.object({
deckIds: z.array(z.string().min(1)).max(1000),
});
export const reviewSpacedRepetitionCardSchema = z.object({
flashcardId: z.string().min(1),
rating: z.enum(SPACED_REPETITION_RATINGS),
expectedStateVersion: z.string().datetime().nullable(),
});
export type CreateSpacedRepetitionSetInput = z.infer<
typeof createSpacedRepetitionSetSchema
>;
export type UpdateSpacedRepetitionSetInput = z.infer<
typeof updateSpacedRepetitionSetSchema
>;

View file

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
function slugify(name: string): string {
return name
@ -8,13 +9,27 @@ function slugify(name: string): string {
}
export async function listClasses() {
return prisma.class.findMany({
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { decks: true, quizSets: true },
const [classes, studyAvailability] = await Promise.all([
prisma.class.findMany({
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { decks: true, quizSets: true },
},
},
},
}),
getStudyAvailabilityByClass(),
]);
return classes.map((classItem) => {
const availability = studyAvailability[classItem.id];
return {
...classItem,
spacedRepetitionDueCards: availability?.dueCards ?? 0,
spacedRepetitionLearningCards: availability?.learningCards ?? 0,
spacedRepetitionNewCards: availability?.newCards ?? 0,
spacedRepetitionReadyCards: availability?.readyCards ?? 0,
};
});
}

View file

@ -0,0 +1,448 @@
import { prisma } from "@/lib/db";
import {
calculateQueueCounts,
getArizonaDayBounds,
getEffectiveDue,
getLearnAheadCutoff,
INTRADAY_LEARNING_STATES,
previewRatings,
scheduleRating,
type SpacedRepetitionRating,
type StoredScheduleState,
} from "@/lib/spacedRepetition";
import type {
CreateSpacedRepetitionSetInput,
UpdateSpacedRepetitionSetInput,
} from "@/lib/validation/spacedRepetitionSchemas";
export class SpacedRepetitionServiceError extends Error {
constructor(message: string, public status: 400 | 404 | 409) {
super(message);
}
}
const cardStateSelect = {
id: true,
due: true,
stability: true,
difficulty: true,
elapsedDays: true,
scheduledDays: true,
learningSteps: true,
reps: true,
lapses: true,
state: true,
lastReview: true,
updatedAt: true,
} as const;
function asStoredState(state: {
due: Date;
stability: number;
difficulty: number;
elapsedDays: number;
scheduledDays: number;
learningSteps: number;
reps: number;
lapses: number;
state: number;
lastReview: Date | null;
}): StoredScheduleState {
return state;
}
async function getSetSummary(setId: string, now: Date) {
const { start, end } = getArizonaDayBounds(now);
const set = await prisma.spacedRepetitionSet.findUnique({
where: { id: setId },
include: {
class: { select: { id: true, slug: true, name: true } },
decks: {
orderBy: { sortOrder: "asc" },
include: {
deck: {
select: {
id: true,
name: true,
description: true,
_count: { select: { cards: true } },
},
},
},
},
cardStates: {
select: {
due: true,
firstReviewedAt: true,
scheduledDays: true,
state: true,
},
},
},
});
if (!set) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
const totalCards = set.decks.reduce(
(sum, membership) => sum + membership.deck._count.cards,
0
);
const reviewedCards = set.cardStates.length;
const dueCards = set.cardStates.filter(
(state) => getEffectiveDue(state) <= now
).length;
const learnAheadCutoff = getLearnAheadCutoff(now);
const learningAheadCards = set.cardStates.filter(
(state) =>
getEffectiveDue(state) > now &&
getEffectiveDue(state) <= learnAheadCutoff &&
INTRADAY_LEARNING_STATES.includes(state.state)
).length;
const introducedToday = set.cardStates.filter(
(state) => state.firstReviewedAt >= start && state.firstReviewedAt < end
).length;
const { unseenCards, newCardsAvailable, availableCards } = calculateQueueCounts({
totalCards,
reviewedCards,
dueCards,
learningAheadCards,
introducedToday,
newCardsPerDay: set.newCardsPerDay,
});
const nextDue = set.cardStates
.map((state) => getEffectiveDue(state))
.filter((due) => due > now)
.sort((a, b) => a.getTime() - b.getTime())[0];
return {
id: set.id,
classId: set.classId,
name: set.name,
description: set.description,
newCardsPerDay: set.newCardsPerDay,
sortOrder: set.sortOrder,
createdAt: set.createdAt,
updatedAt: set.updatedAt,
class: set.class,
decks: set.decks.map((membership) => ({
...membership.deck,
sortOrder: membership.sortOrder,
})),
stats: {
totalCards,
reviewedCards,
unseenCards,
dueCards,
learningAheadCards,
newCardsAvailable,
availableCards,
nextDue: nextDue?.toISOString() ?? null,
},
};
}
export async function listSetsByClass(classId: string, now = new Date()) {
const ids = await prisma.spacedRepetitionSet.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
select: { id: true },
});
return Promise.all(ids.map(({ id }) => getSetSummary(id, now)));
}
export async function getStudyAvailabilityByClass(now = new Date()) {
const { start, end } = getArizonaDayBounds(now);
const learnAheadCutoff = getLearnAheadCutoff(now);
const sets = await prisma.spacedRepetitionSet.findMany({
select: {
classId: true,
newCardsPerDay: true,
decks: {
select: {
deck: { select: { _count: { select: { cards: true } } } },
},
},
cardStates: {
select: {
due: true,
firstReviewedAt: true,
scheduledDays: true,
state: true,
},
},
},
});
return sets.reduce<Record<string, {
dueCards: number;
learningCards: number;
newCards: number;
readyCards: number;
}>>((availability, set) => {
const totalCards = set.decks.reduce(
(sum, membership) => sum + membership.deck._count.cards,
0
);
const dueCards = set.cardStates.filter(
(state) => getEffectiveDue(state) <= now
).length;
const learningCards = set.cardStates.filter((state) => {
const effectiveDue = getEffectiveDue(state);
return effectiveDue > now &&
effectiveDue <= learnAheadCutoff &&
INTRADAY_LEARNING_STATES.includes(state.state);
}).length;
const introducedToday = set.cardStates.filter(
(state) => state.firstReviewedAt >= start && state.firstReviewedAt < end
).length;
const counts = calculateQueueCounts({
totalCards,
reviewedCards: set.cardStates.length,
dueCards,
learningAheadCards: learningCards,
introducedToday,
newCardsPerDay: set.newCardsPerDay,
});
const current = availability[set.classId] ?? {
dueCards: 0,
learningCards: 0,
newCards: 0,
readyCards: 0,
};
availability[set.classId] = {
dueCards: current.dueCards + dueCards,
learningCards: current.learningCards + learningCards,
newCards: current.newCards + counts.newCardsAvailable,
readyCards: current.readyCards + counts.availableCards,
};
return availability;
}, {});
}
export async function createSet(data: CreateSpacedRepetitionSetInput) {
const classExists = await prisma.class.findUnique({
where: { id: data.classId },
select: { id: true },
});
if (!classExists) throw new SpacedRepetitionServiceError("Class not found", 404);
const maximum = await prisma.spacedRepetitionSet.aggregate({
where: { classId: data.classId },
_max: { sortOrder: true },
});
return prisma.spacedRepetitionSet.create({
data: {
classId: data.classId,
name: data.name,
description: data.description || null,
newCardsPerDay: data.newCardsPerDay,
sortOrder: (maximum._max.sortOrder ?? -1) + 1,
},
});
}
export async function getSet(id: string, now = new Date()) {
return getSetSummary(id, now);
}
export async function updateSet(id: string, data: UpdateSpacedRepetitionSetInput) {
const exists = await prisma.spacedRepetitionSet.findUnique({ where: { id } });
if (!exists) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
return prisma.spacedRepetitionSet.update({
where: { id },
data: {
name: data.name,
description: data.description === undefined ? undefined : data.description || null,
newCardsPerDay: data.newCardsPerDay,
},
});
}
export async function deleteSet(id: string) {
const result = await prisma.spacedRepetitionSet.deleteMany({ where: { id } });
if (!result.count) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
}
export async function addDeck(setId: string, deckId: string) {
const [set, deck, existing] = await Promise.all([
prisma.spacedRepetitionSet.findUnique({ where: { id: setId } }),
prisma.deck.findUnique({ where: { id: deckId } }),
prisma.spacedRepetitionSetDeck.findUnique({
where: { setId_deckId: { setId, deckId } },
}),
]);
if (!set) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
if (!deck) throw new SpacedRepetitionServiceError("Deck not found", 404);
if (set.classId !== deck.classId) {
throw new SpacedRepetitionServiceError("Deck belongs to another class", 400);
}
if (existing) throw new SpacedRepetitionServiceError("Deck is already in this set", 409);
const maximum = await prisma.spacedRepetitionSetDeck.aggregate({
where: { setId },
_max: { sortOrder: true },
});
return prisma.spacedRepetitionSetDeck.create({
data: { setId, deckId, sortOrder: (maximum._max.sortOrder ?? -1) + 1 },
});
}
export async function removeDeck(setId: string, deckId: string) {
const membership = await prisma.spacedRepetitionSetDeck.findUnique({
where: { setId_deckId: { setId, deckId } },
});
if (!membership) throw new SpacedRepetitionServiceError("Deck membership not found", 404);
await prisma.$transaction([
prisma.spacedRepetitionCardState.deleteMany({
where: { setId, flashcard: { deckId } },
}),
prisma.spacedRepetitionSetDeck.delete({
where: { setId_deckId: { setId, deckId } },
}),
]);
}
export async function reorderDecks(setId: string, deckIds: string[]) {
if (new Set(deckIds).size !== deckIds.length) {
throw new SpacedRepetitionServiceError("Deck order contains duplicates", 400);
}
const memberships = await prisma.spacedRepetitionSetDeck.findMany({
where: { setId },
select: { deckId: true },
});
const existing = new Set(memberships.map((item) => item.deckId));
if (existing.size !== deckIds.length || deckIds.some((id) => !existing.has(id))) {
throw new SpacedRepetitionServiceError("Deck order must include every set deck", 400);
}
await prisma.$transaction(
deckIds.map((deckId, sortOrder) =>
prisma.spacedRepetitionSetDeck.update({
where: { setId_deckId: { setId, deckId } },
data: { sortOrder },
})
)
);
}
async function findNextCard(setId: string, now: Date, allowNew: boolean) {
const { end } = getArizonaDayBounds(now);
const due = await prisma.spacedRepetitionCardState.findFirst({
where: {
setId,
OR: [
{ scheduledDays: 0, due: { lte: now } },
{ scheduledDays: { gte: 1 }, due: { lt: end } },
],
flashcard: { deck: { spacedRepetitionMemberships: { some: { setId } } } },
},
orderBy: { due: "asc" },
include: { flashcard: { include: { deck: { select: { name: true } } } } },
});
if (due) {
return {
flashcard: due.flashcard,
updatedAt: due.updatedAt,
state: asStoredState(due),
};
}
if (allowNew) {
const memberships = await prisma.spacedRepetitionSetDeck.findMany({
where: { setId },
orderBy: { sortOrder: "asc" },
select: { deckId: true },
});
for (const membership of memberships) {
const card = await prisma.flashcard.findFirst({
where: {
deckId: membership.deckId,
spacedRepetitionStates: { none: { setId } },
},
orderBy: { sortOrder: "asc" },
include: { deck: { select: { name: true } } },
});
if (card) return { flashcard: card, updatedAt: null, state: null };
}
}
const learningAhead = await prisma.spacedRepetitionCardState.findFirst({
where: {
setId,
state: { in: INTRADAY_LEARNING_STATES },
due: { gt: now, lte: getLearnAheadCutoff(now) },
flashcard: { deck: { spacedRepetitionMemberships: { some: { setId } } } },
},
orderBy: { due: "asc" },
include: { flashcard: { include: { deck: { select: { name: true } } } } },
});
if (learningAhead) {
return {
flashcard: learningAhead.flashcard,
updatedAt: learningAhead.updatedAt,
state: asStoredState(learningAhead),
};
}
return null;
}
export async function getStudyState(setId: string, now = new Date()) {
const summary = await getSetSummary(setId, now);
const next = await findNextCard(setId, now, summary.stats.newCardsAvailable > 0);
return {
set: summary,
currentCard: next
? {
id: next.flashcard.id,
front: next.flashcard.front,
back: next.flashcard.back,
sourceDeckName: next.flashcard.deck.name,
stateVersion: next.updatedAt?.toISOString() ?? null,
previews: previewRatings(next.state, now),
}
: null,
};
}
export async function reviewCard(data: {
setId: string;
flashcardId: string;
rating: SpacedRepetitionRating;
expectedStateVersion: string | null;
now?: Date;
}) {
const now = data.now ?? new Date();
const card = await prisma.flashcard.findFirst({
where: {
id: data.flashcardId,
deck: { spacedRepetitionMemberships: { some: { setId: data.setId } } },
},
});
if (!card) throw new SpacedRepetitionServiceError("Card is not in this repetition set", 404);
const current = await prisma.spacedRepetitionCardState.findUnique({
where: { setId_flashcardId: { setId: data.setId, flashcardId: data.flashcardId } },
select: { ...cardStateSelect, firstReviewedAt: true },
});
const currentVersion = current?.updatedAt.toISOString() ?? null;
if (currentVersion !== data.expectedStateVersion) {
throw new SpacedRepetitionServiceError("This card was already reviewed. Refreshing the queue is required.", 409);
}
const next = scheduleRating(current ? asStoredState(current) : null, data.rating, now);
await prisma.$transaction(async (tx) => {
if (current) {
const updated = await tx.spacedRepetitionCardState.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: next,
});
if (!updated.count) throw new SpacedRepetitionServiceError("Card state changed", 409);
} else {
await tx.spacedRepetitionCardState.create({
data: { setId: data.setId, flashcardId: data.flashcardId, ...next, firstReviewedAt: now },
});
}
await tx.studyActivity.create({ data: { type: "FLASHCARD", occurredAt: now } });
});
return getStudyState(data.setId, now);
}

13
vitest.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "node",
},
});