From 9ddf67148c117aa99720993bacb91a9d1d7eb44a Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Sat, 20 Jun 2026 18:24:02 +0000 Subject: [PATCH] =?UTF-8?q?Initial:=20Intelligrade=20Lite=20=E2=80=94=20cl?= =?UTF-8?q?ient-side=20digital=20exam=20platform=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + index.html | 13 + package-lock.json | 1158 +++++++++++++++++++++++++++++++++++++++ package.json | 16 + src/main.ts | 801 +++++++++++++++++++++++++++ src/store.ts | 53 ++ src/style.css | 626 +++++++++++++++++++++ src/types.ts | 49 ++ src/utils.ts | 86 +++ tests/grade.test.js | 95 ++++ tests/grade.test.js.map | 1 + tests/grade.test.ts | 105 ++++ tsconfig.json | 19 + vite.config.ts | 11 + 14 files changed, 3038 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/main.ts create mode 100644 src/store.ts create mode 100644 src/style.css create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 tests/grade.test.js create mode 100644 tests/grade.test.js.map create mode 100644 tests/grade.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edf243d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.venv/ +.verdict diff --git a/index.html b/index.html new file mode 100644 index 0000000..3e57734 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Intelligrade Lite — Digital Exams + + + +
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..efc4a82 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1158 @@ +{ + "name": "intelligrade-lite", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "intelligrade-lite", + "version": "1.0.0", + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a73d273 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "intelligrade-lite", + "version": "1.0.0", + "description": "Client-side digital exam creation and grading tool", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "node tests/grade.test.js" + }, + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.0.0" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..173c1a0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,801 @@ +import { View, Exam, Submission } from './types'; +import { saveExams, loadExams, saveSubmissions, loadSubmissions, generateId, exportToJSON, importFromJSON } from './store'; +import { autoGradeSubmission, formatTime } from './utils'; + +// App state +let currentView: View = 'dashboard'; +let editingExamId: string | null = null; +let takingExamId: string | null = null; +let gradingSubmissionId: string | null = null; +let viewingResultsExamId: string | null = null; + +// DOM +const app = document.getElementById('app')!; + +function render() { + app.innerHTML = ''; + switch (currentView) { + case 'dashboard': renderDashboard(); break; + case 'editor': renderEditor(); break; + case 'take': renderTake(); break; + case 'submissions': renderSubmissions(); break; + case 'grade': renderGrade(); break; + case 'results': renderResults(); break; + case 'export': renderExport(); break; + } +} + +function navigate(view: View, params?: { examId?: string; submissionId?: string }) { + currentView = view; + editingExamId = params?.examId ?? null; + takingExamId = params?.examId ?? null; + gradingSubmissionId = params?.submissionId ?? null; + viewingResultsExamId = params?.examId ?? null; + render(); +} + +function renderNav(): string { + return ` + + `; +} + +// Make navigate available globally for inline handlers +(window as any).navigate = navigate; + +function renderDashboard() { + const exams = loadExams(); + const submissions = loadSubmissions(); + + app.innerHTML = ` + ${renderNav()} +
+
+

Digital Exams

+

Create, conduct, and grade exams — all in your browser. No server. No data leaves your device.

+ +
+ +
+
+
${exams.length}
+
Exams
+
+
+
${submissions.length}
+
Submissions
+
+
+
${submissions.filter(s => s.graded).length}
+
Graded
+
+
+ +

Your Exams

+ ${exams.length === 0 ? ` +
+

No exams yet. Create your first exam to get started.

+
+ ` : ` +
+ ${exams.map(exam => ` +
+
+

${escapeHtml(exam.title)}

+ ${exam.questions.length} questions +
+

${escapeHtml(exam.subject)}

+

${escapeHtml(exam.description)}

+
+ ⏱ ${exam.timeLimit > 0 ? exam.timeLimit + ' min' : 'No limit'} + 🔀 ${exam.shuffleQuestions ? 'Shuffled' : 'Fixed order'} +
+
+ + + + +
+
+ `).join('')} +
+ `} + +

Recent Submissions

+ ${submissions.length === 0 ? ` +
+

No submissions yet. Take an exam to see results here.

+
+ ` : ` +
+ ${submissions.slice(0, 10).map(sub => { + const exam = exams.find(e => e.id === sub.examId); + return ` +
+ ${escapeHtml(sub.studentName)} + ${escapeHtml(exam?.title ?? 'Unknown exam')} + ${sub.percentage}% + ${sub.graded ? 'Graded' : 'Pending'} + +
+ `; + }).join('')} +
+ `} +
+ `; +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +(window as any).createNewExam = () => { + editingExamId = null; + navigate('editor'); +}; + +(window as any).editExam = (id: string) => navigate('editor', { examId: id }); +(window as any).takeExam = (id: string) => navigate('take', { examId: id }); +(window as any).viewResults = (id: string) => navigate('results', { examId: id }); +(window as any).gradeSubmission = (id: string) => navigate('grade', { submissionId: id }); + +(window as any).deleteExam = (id: string) => { + if (!confirm('Delete this exam and all its submissions?')) return; + const exams = loadExams().filter(e => e.id !== id); + const submissions = loadSubmissions().filter(s => s.examId !== id); + saveExams(exams); + saveSubmissions(submissions); + render(); +}; + +function renderEditor() { + const exams = loadExams(); + const exam = editingExamId ? exams.find(e => e.id === editingExamId) : null; + + const questions = exam?.questions ?? []; + + app.innerHTML = ` + ${renderNav()} +
+
+ +

${exam ? 'Edit Exam' : 'New Exam'}

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
+
+ +

Questions (${questions.length})

+
+ ${questions.map((q, i) => renderQuestionEditor(q, i)).join('')} +
+ +
+ + + + + +
+ +
+ +
+
+ `; +} + +function renderQuestionEditor(q: any, index: number): string { + const optionsHtml = q.options?.map((opt: string, i: number) => ` +
+ + + +
+ `).join('') ?? ''; + + const optionsEditor = q.type === 'multiple_choice' ? ` +
+ ${optionsHtml} + +
+ ` : ''; + + const correctAnswerInput = q.type === 'fill_blank' ? ` +
+ + + +
+ ` : q.type === 'true_false' ? ` +
+ + +
+ ` : ''; + + return ` +
+
+ ${index + 1} + ${q.type.replace('_', ' ')} + +
+
+
+ + +
+
+ + +
+
+ ${optionsEditor} + ${correctAnswerInput} +
+ `; +} + +(window as any).addQuestion = (type: string) => { + const container = document.getElementById('questions-container')!; + const qid = generateId(); + const newQ = { + id: qid, + type, + text: '', + points: type === 'essay' ? 10 : (type === 'short_answer' ? 5 : 1), + options: type === 'multiple_choice' ? ['', ''] : undefined, + correctAnswer: type === 'true_false' ? 'true' : undefined, + }; + const div = document.createElement('div'); + div.innerHTML = renderQuestionEditor(newQ, container.children.length); + container.appendChild(div.firstElementChild!); +}; + +(window as any).addOption = (qid: string) => { + const list = document.getElementById(`options-${qid}`)!; + const idx = list.querySelectorAll('.option-row').length; + const row = document.createElement('div'); + row.className = 'option-row'; + row.innerHTML = ` + + + + `; + list.insertBefore(row, list.lastElementChild); +}; + +(window as any).removeOption = (qid: string, idx: number) => { + const list = document.getElementById(`options-${qid}`)!; + const rows = list.querySelectorAll('.option-row'); + if (rows[idx]) rows[idx].remove(); +}; + +(window as any).removeQuestion = (qid: string) => { + const el = document.querySelector(`[data-qid="${qid}"]`); + el?.remove(); + // Renumber + document.querySelectorAll('.question-editor').forEach((el, i) => { + el.querySelector('.question-number')!.textContent = String(i + 1); + }); +}; + +(window as any).saveExam = () => { + const title = (document.getElementById('exam-title') as HTMLInputElement).value.trim(); + if (!title) { alert('Title is required'); return; } + + const exam: Exam = { + id: editingExamId ?? generateId(), + title, + subject: (document.getElementById('exam-subject') as HTMLInputElement).value.trim(), + description: (document.getElementById('exam-desc') as HTMLTextAreaElement).value.trim(), + timeLimit: parseInt((document.getElementById('exam-time') as HTMLInputElement).value) || 0, + shuffleQuestions: (document.getElementById('exam-shuffle') as HTMLInputElement).checked, + allowRetake: (document.getElementById('exam-retake') as HTMLInputElement).checked, + showResults: (document.getElementById('exam-showresults') as HTMLInputElement).checked, + questions: [], + createdAt: editingExamId ? loadExams().find(e => e.id === editingExamId)!.createdAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Collect questions from DOM + document.querySelectorAll('.question-editor').forEach(el => { + const qid = el.getAttribute('data-qid')!; + const type = el.querySelector('.question-type-badge')!.textContent!.replace(' ', '_') as any; + const text = (el.querySelector(`#text-${qid}`) as HTMLTextAreaElement).value.trim(); + const points = parseInt((el.querySelector(`#points-${qid}`) as HTMLInputElement).value) || 0; + + const q: any = { id: qid, type, text, points }; + + if (type === 'multiple_choice') { + const options: string[] = []; + let correctAnswer = ''; + el.querySelectorAll('.option-row').forEach((row, i) => { + const optText = (row.querySelector('.option-text') as HTMLInputElement)?.value.trim() ?? ''; + if (optText) options.push(optText); + const radio = row.querySelector('input[type="radio"]') as HTMLInputElement; + if (radio?.checked) correctAnswer = String(i); + }); + q.options = options; + q.correctAnswer = correctAnswer; + } else if (type === 'true_false') { + q.correctAnswer = (el.querySelector(`#correct-${qid}`) as HTMLSelectElement)?.value; + } else if (type === 'fill_blank') { + const raw = (el.querySelector(`#correct-${qid}`) as HTMLInputElement)?.value.trim() ?? ''; + q.correctAnswer = raw ? raw.split(',').map((s: string) => s.trim()).filter(Boolean) : []; + q.caseSensitive = (el.querySelector(`#case-${qid}`) as HTMLInputElement)?.checked; + } + + exam.questions.push(q); + }); + + const exams = loadExams().filter(e => e.id !== exam.id); + exams.push(exam); + saveExams(exams); + navigate('dashboard'); +}; + +function renderTake() { + const exams = loadExams(); + const exam = exams.find(e => e.id === takingExamId); + + if (!exam) { + app.innerHTML = `

Exam not found.

`; + return; + } + + const questions = exam.shuffleQuestions + ? [...exam.questions].sort(() => Math.random() - 0.5) + : exam.questions; + + app.innerHTML = ` + ${renderNav()} +
+
+

${escapeHtml(exam.title)}

+
+ ${exam.questions.length} questions + ${exam.questions.reduce((a, q) => a + q.points, 0)} points + ${exam.timeLimit > 0 ? `⏱ ${formatTime(exam.timeLimit * 60)}` : ''} +
+
+ +
+
+ + +
+
+ +
+ ${questions.map((q, i) => renderQuestionTake(q, i)).join('')} +
+ +
+ +
+
+ `; + + // Start timer + if (exam.timeLimit > 0) { + let remaining = exam.timeLimit * 60; + const timerEl = document.getElementById('timer')!; + const interval = setInterval(() => { + remaining--; + timerEl.textContent = `⏱ ${formatTime(remaining)}`; + if (remaining <= 0) { + clearInterval(interval); + alert('Time is up! Submitting your exam.'); + (window as any).submitExam(); + } + }, 1000); + (window as any)._examTimer = interval; + } +} + +function renderQuestionTake(q: any, index: number): string { + const inputName = `q-${q.id}`; + + switch (q.type) { + case 'multiple_choice': + return ` +
+
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
+
+ ${q.options?.map((opt: string, i: number) => ` + + `).join('') ?? ''} +
+
+ `; + case 'true_false': + return ` +
+
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
+
+ + +
+
+ `; + case 'fill_blank': + return ` +
+
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
+ +
+ `; + case 'short_answer': + return ` +
+
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
+ +
+ `; + case 'essay': + return ` +
+
${index + 1}. ${escapeHtml(q.text)} (${q.points} pts)
+ +
+ `; + default: + return ''; + } +} + +(window as any).submitExam = () => { + const studentName = (document.getElementById('student-name') as HTMLInputElement)?.value.trim(); + if (!studentName) { alert('Please enter your name'); return; } + + const exams = loadExams(); + const exam = exams.find(e => e.id === takingExamId); + if (!exam) return; + + const answers: any[] = []; + exam.questions.forEach(q => { + const inputName = `q-${q.id}`; + if (q.type === 'multiple_choice' || q.type === 'true_false') { + const selected = document.querySelector(`input[name="${inputName}"]:checked`) as HTMLInputElement; + if (selected) answers.push({ questionId: q.id, value: selected.value }); + } else { + const el = document.querySelector(`[name="${inputName}"]`) as HTMLInputElement | HTMLTextAreaElement; + if (el) answers.push({ questionId: q.id, value: el.value }); + } + }); + + if ((window as any)._examTimer) clearInterval((window as any)._examTimer); + + const submission: Submission = { + id: generateId(), + examId: exam.id, + studentName, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + answers, + scores: {}, + totalScore: 0, + maxScore: 0, + percentage: 0, + graded: false, + }; + + const questionsMap = new Map(exam.questions.map(q => [q.id, q])); + const graded = autoGradeSubmission(submission, questionsMap); + + const submissions = loadSubmissions(); + submissions.push(graded); + saveSubmissions(submissions); + + if (exam.showResults) { + alert(`Submitted! You scored ${graded.percentage}% (${graded.totalScore}/${graded.maxScore})`); + } else { + alert('Submitted! Your teacher will review your answers.'); + } + navigate('dashboard'); +}; + +function renderGrade() { + const submissions = loadSubmissions(); + const submission = submissions.find(s => s.id === gradingSubmissionId); + const exams = loadExams(); + const exam = submission ? exams.find(e => e.id === submission.examId) : null; + + if (!submission || !exam) { + app.innerHTML = `

Submission not found.

`; + return; + } + + app.innerHTML = ` + ${renderNav()} +
+
+ +

Grade: ${escapeHtml(submission.studentName)}

+

${escapeHtml(exam.title)}

+
+ +
+
Current: ${submission.totalScore} / ${submission.maxScore} = ${submission.percentage}%
+
+ +
+ ${exam.questions.map(q => { + const answer = submission.answers.find(a => a.questionId === q.id); + const score = submission.scores[q.id] ?? 0; + const needsManual = q.type === 'short_answer' || q.type === 'essay'; + const autoGraded = !needsManual && submission.scores[q.id] !== undefined; + + return ` +
+
${escapeHtml(q.text)} (${q.points} pts)
+
+ Answer: ${escapeHtml(String(answer?.value ?? 'No answer'))} +
+ ${q.correctAnswer ? `
Expected: ${escapeHtml(Array.isArray(q.correctAnswer) ? q.correctAnswer.join(' or ') : q.correctAnswer)}
` : ''} +
+ + + + ${autoGraded ? 'Auto-graded' : (needsManual ? 'Manual' : '')} + +
+
+ `; + }).join('')} +
+ +
+ +
+
+ `; +} + +(window as any).updateScore = (qid: string, maxPoints: number) => { + const el = document.getElementById(`score-${qid}`) as HTMLInputElement; + let val = parseFloat(el.value); + if (val < 0) val = 0; + if (val > maxPoints) val = maxPoints; + el.value = String(val); +}; + +(window as any).saveGrades = () => { + const submissions = loadSubmissions(); + const submission = submissions.find(s => s.id === gradingSubmissionId); + if (!submission) return; + + const exams = loadExams(); + const exam = exams.find(e => e.id === submission.examId); + if (!exam) return; + + let totalScore = 0; + const scores: Record = {}; + + exam.questions.forEach(q => { + const el = document.getElementById(`score-${q.id}`) as HTMLInputElement; + const score = parseFloat(el.value) || 0; + scores[q.id] = score; + totalScore += score; + }); + + const maxScore = exam.questions.reduce((a, q) => a + q.points, 0); + const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0; + + const idx = submissions.findIndex(s => s.id === gradingSubmissionId); + submissions[idx] = { + ...submission, + scores, + totalScore, + maxScore, + percentage, + graded: true, + gradedAt: new Date().toISOString(), + }; + + saveSubmissions(submissions); + alert('Grades saved!'); + navigate('dashboard'); +}; + +function renderResults() { + const exams = loadExams(); + const exam = exams.find(e => e.id === viewingResultsExamId); + const submissions = loadSubmissions().filter(s => s.examId === viewingResultsExamId); + + if (!exam) { + app.innerHTML = `

Exam not found.

`; + return; + } + + const avgScore = submissions.length > 0 + ? Math.round(submissions.reduce((a, s) => a + s.percentage, 0) / submissions.length) + : 0; + const gradedCount = submissions.filter(s => s.graded).length; + + app.innerHTML = ` + ${renderNav()} +
+
+ +

Results: ${escapeHtml(exam.title)}

+
+ +
+
+
${submissions.length}
+
Submissions
+
+
+
${gradedCount}
+
Graded
+
+
+
${avgScore}%
+
Average
+
+
+
${submissions.length > 0 ? Math.max(...submissions.map(s => s.percentage)) : 0}%
+
Highest
+
+
+ +

Submissions

+ ${submissions.length === 0 ? '

No submissions yet.

' : ` +
+ ${submissions.map(sub => ` +
+ ${escapeHtml(sub.studentName)} + ${sub.percentage}% + ${sub.graded ? 'Graded' : 'Pending'} + ${new Date(sub.completedAt).toLocaleDateString()} + +
+ `).join('')} +
+ `} + +

Question Breakdown

+
+ ${exam.questions.map(q => { + const questionSubmissions = submissions.map(sub => { + const answer = sub.answers.find(a => a.questionId === q.id); + return { score: sub.scores[q.id] ?? 0, answer: answer?.value ?? '' }; + }); + const avgQScore = questionSubmissions.length > 0 + ? Math.round((questionSubmissions.reduce((a, s) => a + s.score, 0) / (questionSubmissions.length * q.points)) * 100) + : 0; + + return ` +
+ ${escapeHtml(q.text.substring(0, 60))}${q.text.length > 60 ? '...' : ''} +
+
+
+ ${avgQScore}% avg +
+ `; + }).join('')} +
+
+ `; +} + +function renderSubmissions() { + navigate('dashboard'); +} + +function renderExport() { + app.innerHTML = ` + ${renderNav()} +
+
+ +

Import / Export

+
+ +
+

Export Exams

+

Download all your exams as a JSON file.

+ +
+ +
+

Export Submissions

+

Download all submissions as a JSON file.

+ +
+ +
+

Import Exams

+

Upload a previously exported exam file.

+ +
+
+ `; +} + +(window as any).exportExams = () => { + exportToJSON(loadExams(), `intelligrade-exams-${new Date().toISOString().slice(0, 10)}.json`); +}; + +(window as any).exportSubmissions = () => { + exportToJSON(loadSubmissions(), `intelligrade-submissions-${new Date().toISOString().slice(0, 10)}.json`); +}; + +(window as any).importExams = async () => { + const input = document.getElementById('import-file') as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + try { + const data = await importFromJSON(file) as Exam[]; + if (!Array.isArray(data)) { alert('Invalid file format'); return; } + + const existing = loadExams(); + const merged = [...existing]; + for (const exam of data) { + if (!existing.find(e => e.id === exam.id)) { + exam.id = generateId(); // Reassign IDs to avoid conflicts + exam.createdAt = new Date().toISOString(); + merged.push(exam); + } + } + saveExams(merged); + alert(`Imported ${data.length} exams`); + input.value = ''; + navigate('dashboard'); + } catch (e) { + alert('Failed to import: ' + (e as Error).message); + } +}; + +// Initial render +render(); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..03cc1d9 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,53 @@ +import { Exam, Submission } from './types'; + +const STORAGE_KEYS = { + exams: 'intelligrade_exams', + submissions: 'intelligrade_submissions', +}; + +export function saveExams(exams: Exam[]): void { + localStorage.setItem(STORAGE_KEYS.exams, JSON.stringify(exams)); +} + +export function loadExams(): Exam[] { + const raw = localStorage.getItem(STORAGE_KEYS.exams); + return raw ? JSON.parse(raw) : []; +} + +export function saveSubmissions(submissions: Submission[]): void { + localStorage.setItem(STORAGE_KEYS.submissions, JSON.stringify(submissions)); +} + +export function loadSubmissions(): Submission[] { + const raw = localStorage.getItem(STORAGE_KEYS.submissions); + return raw ? JSON.parse(raw) : []; +} + +export function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +export function exportToJSON(data: unknown, filename: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function importFromJSON(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + try { + resolve(JSON.parse(reader.result as string)); + } catch (e) { + reject(e); + } + }; + reader.onerror = reject; + reader.readAsText(file); + }); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..683f1ec --- /dev/null +++ b/src/style.css @@ -0,0 +1,626 @@ +/* Intelligrade Lite — Digital Exam Platform Styles */ + +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --accent: #0ea5e9; + --danger: #dc2626; + --success: #16a34a; + --warning: #ca8a04; + --bg: #f8fafc; + --card: #ffffff; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px rgba(0,0,0,0.1); + --shadow-lg: 0 4px 12px rgba(0,0,0,0.1); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +/* Navigation */ +.nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background: var(--card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary); + cursor: pointer; +} + +.nav-links { display: flex; gap: 0.5rem; } + +.nav-btn { + padding: 0.5rem 1rem; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius); + font-size: 0.875rem; + transition: all 0.2s; +} + +.nav-btn:hover, .nav-btn.active { + background: var(--bg); + color: var(--primary); +} + +/* Container */ +.container { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1rem; +} + +/* Hero */ +.hero { + text-align: center; + padding: 3rem 1rem; + margin-bottom: 2rem; +} + +.hero h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; + color: var(--text); +} + +.hero p { + color: var(--text-muted); + font-size: 1.1rem; + max-width: 600px; + margin: 0 auto 1.5rem; +} + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.btn:hover { background: var(--bg); } + +.btn-primary { + background: var(--primary); + color: white; + border-color: var(--primary); +} +.btn-primary:hover { background: var(--primary-dark); } + +.btn-accent { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.btn-accent:hover { background: #0284c7; } + +.btn-danger { + background: var(--danger); + color: white; + border-color: var(--danger); +} +.btn-danger:hover { background: #b91c1c; } + +.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } +.btn-large { padding: 0.75rem 1.5rem; font-size: 1rem; } +.btn-back { margin-bottom: 1rem; } +.btn-icon { + width: 28px; + height: 28px; + padding: 0; + justify-content: center; + font-size: 1rem; +} + +/* Stats */ +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--card); + padding: 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +/* Exam Grid */ +.exam-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.exam-card { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.5rem; + display: flex; + flex-direction: column; +} + +.exam-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.exam-card-header h3 { + font-size: 1.1rem; + font-weight: 600; +} + +.badge { + background: var(--bg); + color: var(--text-muted); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; +} + +.exam-subject { + color: var(--primary); + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.exam-desc { + color: var(--text-muted); + font-size: 0.875rem; + margin-bottom: 1rem; + flex-grow: 1; +} + +.exam-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.exam-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Submission List */ +.submission-list { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.submission-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--border); + gap: 1rem; +} + +.submission-row:last-child { border-bottom: none; } + +.sub-student { font-weight: 500; min-width: 120px; } +.sub-exam { color: var(--text-muted); font-size: 0.875rem; flex-grow: 1; } +.sub-score { font-weight: 600; color: var(--primary); } +.sub-status { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); +} +.sub-status.graded { background: #dcfce7; color: var(--success); } +.sub-status.pending { background: #fef9c3; color: var(--warning); } +.sub-date { font-size: 0.75rem; color: var(--text-muted); } + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-muted); + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +/* Editor */ +.editor-header { + margin-bottom: 1.5rem; +} + +.editor-header h1 { + font-size: 1.5rem; + margin-top: 0.5rem; +} + +.editor-form { + background: var(--card); + padding: 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.form-group { margin-bottom: 1rem; } +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.375rem; + color: var(--text); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.625rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + background: var(--bg); + color: var(--text); +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); +} + +.form-row { + display: flex; + gap: 1rem; +} +.form-row .form-group { flex: 1; } +.flex-grow { flex-grow: 1; } + +.form-checkboxes { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.form-checkboxes label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + cursor: pointer; +} + +/* Question Editor */ +.question-editor { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; +} + +.question-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.question-number { + background: var(--primary); + color: white; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; +} + +.question-type-badge { + background: var(--card); + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + text-transform: capitalize; + color: var(--text-muted); + flex-grow: 1; +} + +.options-list { margin: 0.5rem 0; } +.option-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.option-row input[type="radio"] { margin-right: 0.25rem; } +.option-text { flex-grow: 1; } + +.add-question-bar { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.editor-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; +} + +/* Take Exam */ +.take-header { + background: var(--card); + padding: 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.take-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } + +.take-meta { + display: flex; + gap: 1rem; + color: var(--text-muted); + font-size: 0.875rem; +} + +.timer { + font-weight: 600; + color: var(--danger); +} + +.student-form { + background: var(--card); + padding: 1rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1rem; +} + +.questions-list { margin-bottom: 1rem; } + +.question-take { + background: var(--card); + padding: 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1rem; +} + +.question-text { + font-weight: 500; + margin-bottom: 1rem; + font-size: 1rem; +} + +.question-text .points { + color: var(--text-muted); + font-weight: 400; + font-size: 0.875rem; +} + +.options-take { display: flex; flex-direction: column; gap: 0.5rem; } + +.option-label { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.option-label:hover { background: var(--bg); } +.option-label input { margin-right: 0.5rem; } + +.answer-input, +.answer-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + background: var(--bg); +} + +.answer-textarea { resize: vertical; min-height: 100px; } + +.take-actions { + display: flex; + justify-content: center; + padding: 1rem 0; +} + +/* Grade */ +.grade-exam-title { + color: var(--text-muted); + font-size: 0.875rem; +} + +.grade-summary { + background: var(--card); + padding: 1rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1rem; + text-align: center; +} + +.grade-score { + font-size: 1.25rem; + font-weight: 600; +} + +.questions-grade { margin-bottom: 1rem; } + +.question-grade { + background: var(--card); + padding: 1rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1rem; +} + +.answer-display { + background: var(--bg); + padding: 0.75rem; + border-radius: var(--radius); + margin: 0.5rem 0; + font-size: 0.875rem; +} + +.correct-answer { + color: var(--success); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.grade-input { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.grade-input input { + width: 80px; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.auto-badge { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); +} +.auto-badge.auto { background: #dcfce7; color: var(--success); } +.auto-badge.manual { background: #fef9c3; color: var(--warning); } + +/* Results */ +.question-breakdown { margin-top: 1rem; } + +.breakdown-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.breakdown-text { + flex: 0 0 200px; + font-size: 0.875rem; +} + +.breakdown-bar { + flex-grow: 1; + height: 8px; + background: var(--bg); + border-radius: 4px; + overflow: hidden; +} + +.breakdown-fill { + height: 100%; + background: var(--primary); + border-radius: 4px; + transition: width 0.3s; +} + +.breakdown-score { + font-size: 0.875rem; + color: var(--text-muted); + min-width: 60px; + text-align: right; +} + +/* Export */ +.export-section { + background: var(--card); + padding: 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1rem; +} + +.export-section h2 { font-size: 1.1rem; margin-bottom: 0.5rem; } +.export-section p { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem; } + +/* Responsive */ +@media (max-width: 640px) { + .nav { padding: 1rem; } + .container { padding: 1rem; } + .hero { padding: 2rem 1rem; } + .hero h1 { font-size: 1.75rem; } + .exam-grid { grid-template-columns: 1fr; } + .form-row { flex-direction: column; gap: 0; } + .submission-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; } + .breakdown-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .breakdown-text { flex: none; } + .breakdown-bar { width: 100%; } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..192d687 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,49 @@ +// Core types for the exam platform + +export type QuestionType = 'multiple_choice' | 'true_false' | 'short_answer' | 'essay' | 'fill_blank'; + +export interface Question { + id: string; + type: QuestionType; + text: string; + points: number; + options?: string[]; // for multiple_choice + correctAnswer?: string | string[]; // for auto-grading + caseSensitive?: boolean; // for fill_blank +} + +export interface Exam { + id: string; + title: string; + description: string; + subject: string; + timeLimit: number; // minutes, 0 = no limit + shuffleQuestions: boolean; + allowRetake: boolean; + showResults: boolean; + questions: Question[]; + createdAt: string; + updatedAt: string; +} + +export interface Answer { + questionId: string; + value: string | string[]; +} + +export interface Submission { + id: string; + examId: string; + studentName: string; + startedAt: string; + completedAt: string; + answers: Answer[]; + scores: Record; // questionId -> score + totalScore: number; + maxScore: number; + percentage: number; + graded: boolean; + gradedAt?: string; +} + +export type View = 'dashboard' | 'editor' | 'take' | 'submissions' | 'grade' | 'results' | 'export'; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6ae68ad --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,86 @@ +import { Question, Answer, Submission } from './types'; + +export function gradeQuestion(question: Question, answer: Answer): number { + const answerValue = answer.value; + + switch (question.type) { + case 'multiple_choice': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + + case 'true_false': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + + case 'fill_blank': { + if (typeof answerValue !== 'string' || !question.correctAnswer) return 0; + const correct = Array.isArray(question.correctAnswer) + ? question.correctAnswer + : [question.correctAnswer]; + const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase(); + for (const c of correct) { + const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase(); + if (submitted === expected) return question.points; + } + return 0; + } + + case 'short_answer': + case 'essay': + // Manual grading required + return -1; + + default: + return 0; + } +} + +export function autoGradeSubmission(submission: Submission, questions: Map): Submission { + const scores: Record = { ...submission.scores }; + let totalScore = 0; + let needsGrading = false; + let maxScore = 0; + + for (const q of questions.values()) { + maxScore += q.points; + const answer = submission.answers.find(a => a.questionId === q.id); + if (answer) { + const score = gradeQuestion(q, answer); + if (score === -1) { + needsGrading = true; + scores[q.id] = scores[q.id] ?? 0; + } else { + scores[q.id] = score; + totalScore += score; + } + } else { + scores[q.id] = 0; + } + } + + // Add up any manual scores already set + for (const [_qid, score] of Object.entries(scores)) { + if (score !== -1 && !totalScore) { + totalScore += score; + } + } + + return { + ...submission, + scores, + totalScore: Object.values(scores).reduce((a, b) => a + b, 0), + maxScore, + percentage: maxScore > 0 ? Math.round((Object.values(scores).reduce((a, b) => a + b, 0) / maxScore) * 100) : 0, + graded: !needsGrading, + }; +} + +export function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} diff --git a/tests/grade.test.js b/tests/grade.test.js new file mode 100644 index 0000000..8bf2b34 --- /dev/null +++ b/tests/grade.test.js @@ -0,0 +1,95 @@ +import assert from 'node:assert'; +import test from 'node:test'; +// Test grading utilities +function gradeQuestion(question, answer) { + const answerValue = answer.value; + switch (question.type) { + case 'multiple_choice': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + case 'true_false': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + case 'fill_blank': { + if (typeof answerValue !== 'string' || !question.correctAnswer) + return 0; + const correct = Array.isArray(question.correctAnswer) + ? question.correctAnswer + : [question.correctAnswer]; + const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase(); + for (const c of correct) { + const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase(); + if (submitted === expected) + return question.points; + } + return 0; + } + case 'short_answer': + case 'essay': + return -1; + default: + return 0; + } +} +test('grade multiple choice - correct', () => { + const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] }; + const a = { questionId: '1', value: '1' }; + assert.strictEqual(gradeQuestion(q, a), 2); +}); +test('grade multiple choice - incorrect', () => { + const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] }; + const a = { questionId: '1', value: '0' }; + assert.strictEqual(gradeQuestion(q, a), 0); +}); +test('grade true/false - correct', () => { + const q = { type: 'true_false', points: 1, correctAnswer: 'true' }; + const a = { questionId: '1', value: 'true' }; + assert.strictEqual(gradeQuestion(q, a), 1); +}); +test('grade fill_blank - exact match', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'mitochondria' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); +test('grade fill_blank - case insensitive', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'Mitochondria' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); +test('grade fill_blank - multiple acceptable', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria', 'Mitochondrion'], caseSensitive: false }; + const a = { questionId: '1', value: 'Mitochondrion' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); +test('grade fill_blank - no match', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'nucleus' }; + assert.strictEqual(gradeQuestion(q, a), 0); +}); +test('grade essay - requires manual', () => { + const q = { type: 'essay', points: 10 }; + const a = { questionId: '1', value: 'An essay...' }; + assert.strictEqual(gradeQuestion(q, a), -1); +}); +test('grade short_answer - requires manual', () => { + const q = { type: 'short_answer', points: 5 }; + const a = { questionId: '1', value: 'Short answer' }; + assert.strictEqual(gradeQuestion(q, a), -1); +}); +// Test store utilities +function generateId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} +test('generateId produces unique strings', () => { + const id1 = generateId(); + const id2 = generateId(); + assert.strictEqual(typeof id1, 'string'); + assert.strictEqual(id1.length > 0, true); + assert.notStrictEqual(id1, id2); +}); +console.log('All tests passed!'); +//# sourceMappingURL=grade.test.js.map \ No newline at end of file diff --git a/tests/grade.test.js.map b/tests/grade.test.js.map new file mode 100644 index 0000000..758082f --- /dev/null +++ b/tests/grade.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"grade.test.js","sourceRoot":"","sources":["grade.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,yBAAyB;AACzB,SAAS,aAAa,CAAC,QAAa,EAAE,MAAW;IAC/C,MAAM,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC;IACjC,QAAQ,QAAQ,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,iBAAiB;YACpB,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,QAAQ,CAAC,aAAa,EAAE,CAAC;gBAC9D,OAAO,WAAW,KAAK,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;YACtE,CAAC;YACD,OAAO,CAAC,CAAC;QACX,KAAK,YAAY;YACf,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,QAAQ,CAAC,aAAa,EAAE,CAAC;gBAC9D,OAAO,WAAW,KAAK,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;YACtE,CAAC;YACD,OAAO,CAAC,CAAC;QACX,KAAK,YAAY,CAAC,CAAC,CAAC;YAClB,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC,QAAQ,CAAC,aAAa;gBAAE,OAAO,CAAC,CAAC;YACzE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;gBACnD,CAAC,CAAC,QAAQ,CAAC,aAAa;gBACxB,CAAC,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;YAC7B,MAAM,SAAS,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;YACjG,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;gBACxB,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;gBAC5E,IAAI,SAAS,KAAK,QAAQ;oBAAE,OAAO,QAAQ,CAAC,MAAM,CAAC;YACrD,CAAC;YACD,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,cAAc,CAAC;QACpB,KAAK,OAAO;YACV,OAAO,CAAC,CAAC,CAAC;QACZ;YACE,OAAO,CAAC,CAAC;IACb,CAAC;AACH,CAAC;AAED,IAAI,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC3C,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC;IAC/F,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;IAC1C,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,mCAAmC,EAAE,GAAG,EAAE;IAC7C,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC;IAC/F,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;IAC1C,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,4BAA4B,EAAE,GAAG,EAAE;IACtC,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,MAAM,EAAE,CAAC;IACnE,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAC7C,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,gCAAgC,EAAE,GAAG,EAAE;IAC1C,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,cAAc,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;IACnG,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC;IACrD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,qCAAqC,EAAE,GAAG,EAAE;IAC/C,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,cAAc,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;IACnG,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC;IACrD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,wCAAwC,EAAE,GAAG,EAAE;IAClD,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,cAAc,EAAE,eAAe,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;IACpH,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC;IACtD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,6BAA6B,EAAE,GAAG,EAAE;IACvC,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,cAAc,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;IACnG,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAChD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;IACzC,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACxC,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IACpD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC9C,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sCAAsC,EAAE,GAAG,EAAE;IAChD,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IAC9C,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC;IACrD,MAAM,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC9C,CAAC,CAAC,CAAC;AAEH,uBAAuB;AACvB,SAAS,UAAU;IACjB,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,IAAI,CAAC,oCAAoC,EAAE,GAAG,EAAE;IAC9C,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,CAAC,WAAW,CAAC,OAAO,GAAG,EAAE,QAAQ,CAAC,CAAC;IACzC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;IACzC,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAClC,CAAC,CAAC,CAAC;AAEH,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC"} \ No newline at end of file diff --git a/tests/grade.test.ts b/tests/grade.test.ts new file mode 100644 index 0000000..07664bc --- /dev/null +++ b/tests/grade.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert'; +import test from 'node:test'; + +// Test grading utilities +function gradeQuestion(question: any, answer: any): number { + const answerValue = answer.value; + switch (question.type) { + case 'multiple_choice': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + case 'true_false': + if (typeof answerValue === 'string' && question.correctAnswer) { + return answerValue === question.correctAnswer ? question.points : 0; + } + return 0; + case 'fill_blank': { + if (typeof answerValue !== 'string' || !question.correctAnswer) return 0; + const correct = Array.isArray(question.correctAnswer) + ? question.correctAnswer + : [question.correctAnswer]; + const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase(); + for (const c of correct) { + const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase(); + if (submitted === expected) return question.points; + } + return 0; + } + case 'short_answer': + case 'essay': + return -1; + default: + return 0; + } +} + +test('grade multiple choice - correct', () => { + const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] }; + const a = { questionId: '1', value: '1' }; + assert.strictEqual(gradeQuestion(q, a), 2); +}); + +test('grade multiple choice - incorrect', () => { + const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] }; + const a = { questionId: '1', value: '0' }; + assert.strictEqual(gradeQuestion(q, a), 0); +}); + +test('grade true/false - correct', () => { + const q = { type: 'true_false', points: 1, correctAnswer: 'true' }; + const a = { questionId: '1', value: 'true' }; + assert.strictEqual(gradeQuestion(q, a), 1); +}); + +test('grade fill_blank - exact match', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'mitochondria' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); + +test('grade fill_blank - case insensitive', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'Mitochondria' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); + +test('grade fill_blank - multiple acceptable', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria', 'Mitochondrion'], caseSensitive: false }; + const a = { questionId: '1', value: 'Mitochondrion' }; + assert.strictEqual(gradeQuestion(q, a), 3); +}); + +test('grade fill_blank - no match', () => { + const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false }; + const a = { questionId: '1', value: 'nucleus' }; + assert.strictEqual(gradeQuestion(q, a), 0); +}); + +test('grade essay - requires manual', () => { + const q = { type: 'essay', points: 10 }; + const a = { questionId: '1', value: 'An essay...' }; + assert.strictEqual(gradeQuestion(q, a), -1); +}); + +test('grade short_answer - requires manual', () => { + const q = { type: 'short_answer', points: 5 }; + const a = { questionId: '1', value: 'Short answer' }; + assert.strictEqual(gradeQuestion(q, a), -1); +}); + +// Test store utilities +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +test('generateId produces unique strings', () => { + const id1 = generateId(); + const id2 = generateId(); + assert.strictEqual(typeof id1, 'string'); + assert.strictEqual(id1.length > 0, true); + assert.notStrictEqual(id1, id2); +}); + +console.log('All tests passed!'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b3f6be3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e123616 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 3000, + }, +})