vibe coded frontend

This commit is contained in:
MangoPig
2026-05-12 10:34:42 +01:00
parent 0fdec18a15
commit 675285e99d
99 changed files with 57409 additions and 29 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
node_modules
.output
.nitro
dist

13
Frontend/README.md Normal file
View File

@@ -0,0 +1,13 @@
Poppins for Title
Caveat_Brush for Quotes
Black #1D1D1B
purple-100 #F3EEFB
purple-200 #ECE5F6
purple-300 #C1A7E2
purple-400 #7443DA
purple-500 #2D223B
Green #00E456
Yellow #FFF200

View File

@@ -8,9 +8,11 @@
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.16.1",
"@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
"solid-js": "^1.9.5",
"solid-transition-group": "^0.3.0",
"vite": "^7.0.0"
},
"engines": {
@@ -22,6 +24,7 @@
"cssnano": "^8.0.1",
"postcss": "^8.5.14",
"postcss-preset-env": "^11.2.1",
"sass": "^1.99.0"
"sass": "^1.99.0",
"sass-embedded": "^1.99.0"
}
}

348
Frontend/pnpm-lock.yaml generated
View File

@@ -8,18 +8,24 @@ importers:
.:
dependencies:
'@solidjs/router':
specifier: ^0.16.1
version: 0.16.1(solid-js@1.9.11)
'@solidjs/start':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
'@solidjs/vite-plugin-nitro-2':
specifier: ^0.1.0
version: 0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
version: 0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
solid-js:
specifier: ^1.9.5
version: 1.9.11
solid-transition-group:
specifier: ^0.3.0
version: 0.3.0(solid-js@1.9.11)
vite:
specifier: ^7.0.0
version: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
version: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
devDependencies:
'@types/node':
specifier: ^25.6.2
@@ -39,6 +45,9 @@ importers:
sass:
specifier: ^1.99.0
version: 1.99.0
sass-embedded:
specifier: ^1.99.0
version: 1.99.0
packages:
@@ -179,6 +188,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bufbuild/protobuf@2.12.0':
resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==}
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
@@ -1184,11 +1196,31 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@solid-primitives/refs@1.1.3':
resolution: {integrity: sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==}
peerDependencies:
solid-js: ^1.6.12
'@solid-primitives/transition-group@1.1.2':
resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==}
peerDependencies:
solid-js: ^1.6.12
'@solid-primitives/utils@6.4.0':
resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==}
peerDependencies:
solid-js: ^1.6.12
'@solidjs/meta@0.29.4':
resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==}
peerDependencies:
solid-js: '>=1.8.4'
'@solidjs/router@0.16.1':
resolution: {integrity: sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg==}
peerDependencies:
solid-js: ^1.8.6
'@solidjs/start@2.0.0-alpha.2':
resolution: {integrity: sha512-z56ATi3P07q8F5Io2I+RQrwjyWZtFZzpXN/J+8scf/gqrAW83LtgRkZFZjJaGH7i9WrHP+ep9F+ZiJ2gDHVBcw==}
engines: {node: '>=22'}
@@ -1511,6 +1543,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -1931,6 +1966,10 @@ packages:
crossws:
optional: true
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2812,12 +2851,124 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
sass-embedded-all-unknown@1.99.0:
resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==}
cpu: ['!arm', '!arm64', '!riscv64', '!x64']
sass-embedded-android-arm64@1.99.0:
resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.99.0:
resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-riscv64@1.99.0:
resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.99.0:
resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.99.0:
resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.99.0:
resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.99.0:
resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.99.0:
resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-arm64@1.99.0:
resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.99.0:
resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-riscv64@1.99.0:
resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.99.0:
resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.99.0:
resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.99.0:
resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-unknown-all@1.99.0:
resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==}
os: ['!android', '!darwin', '!linux', '!win32']
sass-embedded-win32-arm64@1.99.0:
resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-x64@1.99.0:
resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.99.0:
resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==}
engines: {node: '>=16.0.0'}
hasBin: true
sass@1.99.0:
resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==}
engines: {node: '>=14.0.0'}
@@ -2897,6 +3048,12 @@ packages:
peerDependencies:
solid-js: ^1.3
solid-transition-group@0.3.0:
resolution: {integrity: sha512-gzFbtxkEnA8Hgi7UzmEjPPRl4rodoKLs+AGIXA7kJgjOr4j4qFRw39npu+WpdNSDV7aDKr+K0LFcoT9Kbm5PBA==}
engines: {node: '>=20.0.0', pnpm: '>=9.0.0'}
peerDependencies:
solid-js: ^1.6.12
solid-use@0.9.1:
resolution: {integrity: sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw==}
engines: {node: '>=10'}
@@ -2989,6 +3146,10 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@@ -2998,6 +3159,14 @@ packages:
engines: {node: '>=16'}
hasBin: true
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
sync-message-port@1.2.0:
resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==}
engines: {node: '>=16.0.0'}
system-architecture@0.1.0:
resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==}
engines: {node: '>=18'}
@@ -3055,6 +3224,9 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-fest@5.4.4:
resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
engines: {node: '>=20'}
@@ -3193,6 +3365,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -3509,6 +3684,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bufbuild/protobuf@2.12.0': {}
'@cloudflare/kv-asset-handler@0.4.2': {}
'@colordx/core@5.4.3': {}
@@ -4310,17 +4487,34 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@solid-primitives/refs@1.1.3(solid-js@1.9.11)':
dependencies:
'@solid-primitives/utils': 6.4.0(solid-js@1.9.11)
solid-js: 1.9.11
'@solid-primitives/transition-group@1.1.2(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solid-primitives/utils@6.4.0(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solidjs/meta@0.29.4(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))':
'@solidjs/router@0.16.1(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@solidjs/meta': 0.29.4(solid-js@1.9.11)
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
'@types/babel__traverse': 7.28.0
'@types/micromatch': 4.0.10
cookie-es: 2.0.0
@@ -4342,17 +4536,17 @@ snapshots:
source-map-js: 1.2.1
srvx: 0.9.8
terracotta: 1.1.0(solid-js@1.9.11)
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
transitivePeerDependencies:
- '@testing-library/jest-dom'
- crossws
- supports-color
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))':
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))':
dependencies:
nitropack: 2.13.1
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -4387,7 +4581,7 @@ snapshots:
'@speed-highlight/core@1.2.14': {}
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))':
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4397,7 +4591,7 @@ snapshots:
babel-dead-code-elimination: 1.0.12
pathe: 2.0.3
tiny-invariant: 1.3.3
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
transitivePeerDependencies:
- supports-color
@@ -4414,7 +4608,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))':
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4423,7 +4617,7 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
babel-dead-code-elimination: 1.0.12
tiny-invariant: 1.3.3
transitivePeerDependencies:
@@ -4747,6 +4941,8 @@ snapshots:
color-name@1.1.4: {}
colorjs.io@0.5.2: {}
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -5178,6 +5374,8 @@ snapshots:
optionalDependencies:
crossws: 0.4.4(srvx@0.11.8)
has-flag@4.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -6204,10 +6402,101 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
sass-embedded-all-unknown@1.99.0:
dependencies:
sass: 1.99.0
optional: true
sass-embedded-android-arm64@1.99.0:
optional: true
sass-embedded-android-arm@1.99.0:
optional: true
sass-embedded-android-riscv64@1.99.0:
optional: true
sass-embedded-android-x64@1.99.0:
optional: true
sass-embedded-darwin-arm64@1.99.0:
optional: true
sass-embedded-darwin-x64@1.99.0:
optional: true
sass-embedded-linux-arm64@1.99.0:
optional: true
sass-embedded-linux-arm@1.99.0:
optional: true
sass-embedded-linux-musl-arm64@1.99.0:
optional: true
sass-embedded-linux-musl-arm@1.99.0:
optional: true
sass-embedded-linux-musl-riscv64@1.99.0:
optional: true
sass-embedded-linux-musl-x64@1.99.0:
optional: true
sass-embedded-linux-riscv64@1.99.0:
optional: true
sass-embedded-linux-x64@1.99.0:
optional: true
sass-embedded-unknown-all@1.99.0:
dependencies:
sass: 1.99.0
optional: true
sass-embedded-win32-arm64@1.99.0:
optional: true
sass-embedded-win32-x64@1.99.0:
optional: true
sass-embedded@1.99.0:
dependencies:
'@bufbuild/protobuf': 2.12.0
colorjs.io: 0.5.2
immutable: 5.1.5
rxjs: 7.8.2
supports-color: 8.1.1
sync-child-process: 1.0.2
varint: 6.0.0
optionalDependencies:
sass-embedded-all-unknown: 1.99.0
sass-embedded-android-arm: 1.99.0
sass-embedded-android-arm64: 1.99.0
sass-embedded-android-riscv64: 1.99.0
sass-embedded-android-x64: 1.99.0
sass-embedded-darwin-arm64: 1.99.0
sass-embedded-darwin-x64: 1.99.0
sass-embedded-linux-arm: 1.99.0
sass-embedded-linux-arm64: 1.99.0
sass-embedded-linux-musl-arm: 1.99.0
sass-embedded-linux-musl-arm64: 1.99.0
sass-embedded-linux-musl-riscv64: 1.99.0
sass-embedded-linux-musl-x64: 1.99.0
sass-embedded-linux-riscv64: 1.99.0
sass-embedded-linux-x64: 1.99.0
sass-embedded-unknown-all: 1.99.0
sass-embedded-win32-arm64: 1.99.0
sass-embedded-win32-x64: 1.99.0
sass@1.99.0:
dependencies:
chokidar: 4.0.3
@@ -6303,6 +6592,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
solid-transition-group@0.3.0(solid-js@1.9.11):
dependencies:
'@solid-primitives/refs': 1.1.3(solid-js@1.9.11)
'@solid-primitives/transition-group': 1.1.2(solid-js@1.9.11)
solid-js: 1.9.11
solid-use@0.9.1(solid-js@1.9.11):
dependencies:
solid-js: 1.9.11
@@ -6389,6 +6684,10 @@ snapshots:
supports-color@10.2.2: {}
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
svgo@4.0.1:
@@ -6401,6 +6700,12 @@ snapshots:
picocolors: 1.1.1
sax: 1.6.0
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.2.0
sync-message-port@1.2.0: {}
system-architecture@0.1.0: {}
tagged-tag@1.0.0: {}
@@ -6468,6 +6773,8 @@ snapshots:
trim-lines@3.0.1: {}
tslib@2.8.1: {}
type-fest@5.4.4:
dependencies:
tagged-tag: 1.0.0
@@ -6598,6 +6905,8 @@ snapshots:
util-deprecate@1.0.2: {}
varint@6.0.0: {}
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -6608,7 +6917,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)):
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)):
dependencies:
'@babel/core': 7.29.0
'@types/babel__core': 7.20.5
@@ -6616,12 +6925,12 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.11
solid-refresh: 0.6.3(solid-js@1.9.11)
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0))
transitivePeerDependencies:
- supports-color
vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0):
vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -6634,11 +6943,12 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.99.0
sass-embedded: 1.99.0
terser: 5.46.0
vitefu@1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)):
vitefu@1.1.2(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)):
optionalDependencies:
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.6.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.0)
webidl-conversions@3.0.1: {}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,13 +1,42 @@
// Path: Frontend/src/app.tsx
import { createSignal } from "solid-js";
import { Router, useLocation } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense, Show, type ParentComponent } from "solid-js";
import { Transition } from "solid-transition-group";
import { ThemeProvider } from "./context/theme/context";
import "./styles/main.scss";
export default function App() {
const [count, setCount] = createSignal(0);
const AppRoot: ParentComponent = (props) => {
const location = useLocation();
const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/");
return (
<main>
<h1>AI</h1>
</main>
<Suspense>
<div classList={{
"app-route-shell": true,
"app-route-shell-viewport-locked": isViewportLockedRoute(),
}}>
<Transition name="page-fade-slide">
<Show when={location.pathname} keyed>
{(path) => (
<div class="page-transition-stage" data-route={path}>
{props.children}
</div>
)}
</Show>
</Transition>
</div>
</Suspense>
);
};
export default function App() {
return (
<ThemeProvider>
<Router root={AppRoot}>
<FileRoutes />
</Router>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,60 @@
.headerCard {
display: grid;
gap: 1.1rem;
padding: 1.5rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
color: var(--text-on-accent);
border: 1px solid var(--border-overlay);
box-shadow: var(--shadow-elevated);
}
.headerTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.backLink,
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 0.85rem;
border-radius: 9999px;
background: var(--surface-overlay-soft);
border: 1px solid var(--border-overlay);
color: var(--text-on-accent);
font-size: 0.88rem;
font-weight: 500;
text-decoration: none;
}
.copy {
display: grid;
gap: 0.5rem;
h1 {
font-size: clamp(2rem, 1.55rem + 1.35vw, 3rem);
line-height: 0.98;
letter-spacing: -0.04em;
}
p {
max-width: 55ch;
color: var(--text-on-accent);
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.78rem;
color: var(--text-on-accent-muted) !important;
}
.description {
color: var(--text-on-accent-muted) !important;
font-size: 0.96rem;
}

View File

@@ -0,0 +1,30 @@
import type { Component } from "solid-js";
import { A } from "@solidjs/router";
import type { AssignmentPageData } from "./assignment.data";
import styles from "./assignment-header.module.scss";
type Props = {
data: AssignmentPageData;
};
const AssignmentHeader: Component<Props> = (props) => {
return (
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href="/dashboard" class={styles.backLink}>
Back to dashboard
</A>
<span class={styles.statusPill}>{props.data.statusLabel}</span>
</div>
<div class={styles.copy}>
<p class={styles.eyebrow}>{props.data.classroomName}</p>
<h1>{props.data.title}</h1>
<p>{props.data.headline}</p>
<p class={styles.description}>{props.data.description}</p>
</div>
</section>
);
};
export default AssignmentHeader;

View File

@@ -0,0 +1,85 @@
.stack {
display: grid;
gap: 1rem;
}
.panel {
display: grid;
gap: 1rem;
padding: 1.2rem;
border-radius: 1.35rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.cardHeader {
display: grid;
gap: 0.2rem;
h2 {
font-size: 1.1rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
color: var(--text-muted);
font-size: 0.9rem;
}
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.statCard {
display: grid;
gap: 0.25rem;
padding: 0.9rem;
border-radius: 1rem;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
span {
color: var(--text-muted);
font-size: 0.82rem;
}
strong {
font-size: 1.2rem;
line-height: 1;
}
}
.coachCopy {
color: var(--text-muted);
font-size: 0.94rem;
}
.tipList {
display: grid;
gap: 0.55rem;
padding-left: 1rem;
color: var(--text);
li {
font-size: 0.92rem;
color: var(--text-muted);
}
}
.primaryButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.9rem 1rem;
border-radius: 1rem;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;
box-shadow: var(--action-primary-shadow);
text-decoration: none;
}

View File

@@ -0,0 +1,52 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import type { AssignmentPageData } from "./assignment.data";
import styles from "./assignment-overview.module.scss";
type Props = {
data: AssignmentPageData;
};
const AssignmentOverview: Component<Props> = (props) => {
return (
<div class={styles.stack}>
<section class={styles.panel}>
<div class={styles.cardHeader}>
<h2>Assignment overview</h2>
<p>{props.data.studentName}</p>
</div>
<div class={styles.statGrid}>
<For each={props.data.stats}>
{(stat) => (
<div class={styles.statCard}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
)}
</For>
</div>
</section>
<section class={styles.panel}>
<div class={styles.cardHeader}>
<h2>{props.data.coachCard.title}</h2>
<p>{props.data.tutorName}</p>
</div>
<p class={styles.coachCopy}>{props.data.coachCard.description}</p>
<ul class={styles.tipList}>
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
</ul>
<A href={props.data.primaryHref} class={styles.primaryButton}>
{props.data.primaryAction}
</A>
</section>
</div>
);
};
export default AssignmentOverview;

View File

@@ -0,0 +1,123 @@
.section {
display: grid;
gap: 1rem;
}
.header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
h2 {
font-size: 1.35rem;
line-height: 1.08;
letter-spacing: -0.03em;
}
p {
color: var(--text-muted);
font-size: 0.9rem;
}
}
.list {
display: grid;
gap: 0.9rem;
}
.card {
display: grid;
gap: 0.85rem;
padding: 1.2rem;
border-radius: 1.35rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.cardTop {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
h3 {
font-size: 1.03rem;
line-height: 1.35;
font-weight: 500;
}
}
.order {
margin-bottom: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
color: var(--text-muted);
}
.statusPill {
padding: 0.45rem 0.75rem;
border-radius: 9999px;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.success {
background: var(--surface-success);
color: var(--success);
}
.warning {
background: var(--surface-warning-emphasis);
color: var(--text-warning-strong);
}
.muted {
background: var(--surface-soft);
color: var(--text-muted);
}
.metaRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
span {
padding: 0.35rem 0.6rem;
border-radius: 9999px;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
font-size: 0.82rem;
color: var(--text-muted);
}
}
.responseBlock,
.answerKey {
display: grid;
gap: 0.25rem;
padding: 0.95rem 1rem;
border-radius: 1rem;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
p {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
strong {
font-size: 0.98rem;
font-weight: 600;
}
span {
font-size: 0.9rem;
color: var(--text-muted);
}
}

View File

@@ -0,0 +1,62 @@
import type { Component } from "solid-js";
import { For, Show } from "solid-js";
import type { AssignmentPageData } from "./assignment.data";
import styles from "./assignment-question-list.module.scss";
type Props = {
data: AssignmentPageData;
};
const AssignmentQuestionList: Component<Props> = (props) => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Sample questions</h2>
<p>{props.data.questions.length} loaded from the mock dataset</p>
</div>
<div class={styles.list}>
<For each={props.data.questions}>
{(question) => (
<article class={styles.card}>
<div class={styles.cardTop}>
<div>
<p class={styles.order}>Question {question.order}</p>
<h3>{question.prompt}</h3>
</div>
<span classList={{ [styles.statusPill]: true, [styles[question.statusTone]]: true }}>{question.statusLabel}</span>
</div>
<div class={styles.metaRow}>
<span>{question.topic}</span>
<Show when={question.subTopic}>
<span>{question.subTopic}</span>
</Show>
<span>{question.difficulty}</span>
<span>{question.marks} mark</span>
<Show when={question.solveModeLabel}>
<span>{question.solveModeLabel}</span>
</Show>
</div>
<div class={styles.responseBlock}>
<p>{question.responseLabel}</p>
<strong>{question.responseValue}</strong>
<span>{question.feedback}</span>
</div>
<Show when={question.showAnswerKey}>
<div class={styles.answerKey}>
<p>Answer key</p>
<strong>{question.correctAnswer}</strong>
</div>
</Show>
</article>
)}
</For>
</div>
</section>
);
};
export default AssignmentQuestionList;

View File

@@ -0,0 +1,27 @@
import { A, useLocation, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "../../routes/assignment/assignment-page.module.scss";
const AssignmentTabs: Component = () => {
const params = useParams();
const location = useLocation();
const reviewHref = () => `/assignment/${params.id}`;
const workHref = () => `/assignment/${params.id}/work`;
const isWork = () => location.pathname === workHref();
return (
<nav class={styles.tabs} aria-label="Assignment views">
<div class={styles.tabList}>
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
Review
</A>
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
Work
</A>
</div>
</nav>
);
};
export default AssignmentTabs;

View File

@@ -0,0 +1,251 @@
import rawAssignments from "../../../../Mock-Data/assignments.json";
import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json";
import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json";
import rawQuestionBank from "../../../../Mock-Data/question_bank.json";
import rawStudentAnswers from "../../../../Mock-Data/student_answers.json";
import rawStudents from "../../../../Mock-Data/students.json";
import rawClassroom from "../../../../Mock-Data/classroom.json";
type Assignment = {
id: number;
name: string;
topic: string;
due_date: number;
status: "DRAFT" | "PUBLISHED" | "CLOSED";
maximum_marks: number;
};
type AssignmentAssignee = {
id: number;
assignment_id: number;
student_id: number;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
total_marks: number;
started_at: number | null;
submitted_at: number | null;
};
type AssignmentQuestion = {
id: number;
assignment_id: number;
question_bank_id: number;
question_order: number;
maximum_marks: number;
};
type QuestionBankItem = {
id: number;
topic: string;
sub_topic: string | null;
difficulty: "EASY" | "MEDIUM" | "HARD";
question_text: string;
correct_answer: string;
step_by_step_solution: string | null;
};
type StudentAnswer = {
assignee_id: number;
assignment_question_id: number;
extracted_answer: string;
ai_reasoning: string;
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
_is_correct: boolean;
_time_on_task_seconds: number;
};
type Student = {
id: number;
fullname: string;
_persona: string;
};
type ClassroomFile = {
classroom: {
name: string;
target_level: number;
};
tutor: {
fullname: string;
};
};
export type AssignmentPageData = {
id: number;
title: string;
topic: string;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
statusLabel: string;
dueLabel: string;
studentName: string;
classroomName: string;
tutorName: string;
headline: string;
description: string;
primaryAction: string;
primaryHref: string;
stats: Array<{ label: string; value: string }>;
coachCard: {
title: string;
description: string;
items: string[];
};
questions: Array<{
id: number;
order: number;
prompt: string;
topic: string;
subTopic: string | null;
difficulty: "EASY" | "MEDIUM" | "HARD";
marks: number;
statusLabel: string;
statusTone: "success" | "warning" | "muted";
responseLabel: string;
responseValue: string;
feedback: string;
solveModeLabel?: string;
initialAnswer?: string;
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
showAnswerKey: boolean;
correctAnswer: string;
}>;
};
const assignments = rawAssignments as Assignment[];
const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[];
const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[];
const questionBank = rawQuestionBank as QuestionBankItem[];
const studentAnswers = rawStudentAnswers as StudentAnswer[];
const students = rawStudents as Student[];
const classroomFile = rawClassroom as ClassroomFile;
const defaultStudentId = 201;
const assignmentById = new Map(assignments.map((entry) => [entry.id, entry]));
const questionById = new Map(questionBank.map((entry) => [entry.id, entry]));
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
const formatDate = (timestamp: number) =>
new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(timestamp));
const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => {
switch (value) {
case "just_answer":
return "Just answer";
case "step_by_step":
return "Step by step";
case "solve_together":
return "Solve together";
case "handwritten":
return "Handwritten";
}
};
const personaHint = (persona: string) => {
switch (persona) {
case "fraction_inversion":
return "Slow down on fraction rules and check each step before moving on.";
case "place_value_gaps":
return "Check place value carefully before you calculate the final answer.";
case "rushed_careless":
return "Pause before submitting so small slips do not cost easy marks.";
case "solve_together_dependent":
return "Try one independent attempt first, then ask for guided help if you need it.";
case "word_problem_weak":
return "Underline the key numbers and turn the sentence into a maths step first.";
default:
return "Work through one question at a time and keep your method tidy.";
}
};
export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => {
const assignment = assignmentById.get(assignmentId);
if (!assignment) return null;
const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id);
if (!assignee) return null;
const assignmentQuestionRows = assignmentQuestions
.filter((entry) => entry.assignment_id === assignment.id)
.sort((left, right) => left.question_order - right.question_order)
.map((entry) => {
const question = questionById.get(entry.question_bank_id);
if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`);
const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id);
return { entry, question, answer };
});
const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length;
const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length;
const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0;
const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started";
const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment";
const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({
id: entry.id,
order: entry.question_order,
prompt: question.question_text,
topic: question.topic,
subTopic: question.sub_topic,
difficulty: question.difficulty,
marks: entry.maximum_marks,
statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered",
statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted",
responseLabel: answer ? "Your latest answer" : "Status",
responseValue: answer ? answer.extracted_answer : "No attempt yet",
feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.",
solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined,
initialAnswer: answer?.extracted_answer,
initialSolveMode: answer?._solve_mode,
showAnswerKey: assignee.status === "SUBMITTED",
correctAnswer: question.correct_answer,
}));
return {
id: assignment.id,
title: assignment.name,
topic: assignment.topic,
status: assignee.status,
statusLabel,
dueLabel: formatDate(assignment.due_date),
studentName: student.fullname,
classroomName: classroomFile.classroom.name,
tutorName: classroomFile.tutor.fullname,
headline:
assignee.status === "SUBMITTED"
? `Review how you did in ${assignment.topic}`
: assignee.status === "IN_PROGRESS"
? `Keep going — you are already part way through`
: `Start this assignment with a steady first pass`,
description:
assignee.status === "SUBMITTED"
? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.`
: assignee.status === "IN_PROGRESS"
? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.`
: `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`,
primaryAction,
primaryHref: `/assignment/${assignment.id}/work`,
stats: [
{ label: "Status", value: statusLabel },
{ label: "Due", value: formatDate(assignment.due_date) },
{ label: "Questions", value: `${assignmentQuestionRows.length}` },
{ label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` },
],
coachCard: {
title: "How to approach this one",
description: personaHint(student._persona),
items: [
`${assignment.topic} focus`,
`${accuracy}% accuracy so far`,
`${correctCount} correct answers logged`,
],
},
questions,
};
};

View File

@@ -0,0 +1,170 @@
.section {
display: grid;
gap: 1.05rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.25rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
a {
color: var(--primary);
font-weight: 500;
font-size: 0.9rem;
}
}
@media (max-width: 519px) {
.header a {
font-size: 0.85rem;
}
}
.progressCard,
.highlightCard {
border-radius: 1.35rem;
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.progressCard {
display: grid;
gap: 1rem;
padding: 1rem;
background: var(--surface-panel);
@media (min-width: 640px) {
padding: 1.15rem;
}
@media (min-width: 960px) {
padding: 1.35rem;
}
}
.progressHeader {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
p {
color: var(--text-muted);
font-size: 0.92rem;
}
> div:first-child {
min-width: 0;
flex: 1 1 13rem;
}
}
.progressHeader h3 {
font-size: 1.15rem;
line-height: 1.1;
}
.progressBadge {
padding: 0.45rem 0.7rem;
border-radius: 9999px;
background: var(--surface-info);
color: var(--info);
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
align-self: start;
}
.chart {
width: 100%;
height: auto;
}
.axis {
stroke: var(--border-divider);
stroke-width: 1;
}
.area {
fill: url(#progress-fill);
stroke: none;
}
.line {
fill: none;
stroke: var(--blue-400);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.monthRow {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
@media (max-width: 519px) {
.monthRow {
font-size: 0.72rem;
gap: 0.25rem;
}
}
.highlightGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.85rem;
@media (min-width: 560px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.highlightCard {
display: grid;
gap: 0.35rem;
padding: 1rem;
color: var(--text);
strong {
font-size: 2rem;
font-size: clamp(1.55rem, 1.25rem + 1vw, 2rem);
line-height: 1;
font-weight: 700;
}
h3 {
font-size: 1rem;
font-weight: 600;
}
p {
font-size: 0.9rem;
color: inherit;
opacity: 0.84;
}
}
.yellow {
background: var(--surface-warning-emphasis);
color: var(--text-warning-strong);
}
.pink {
background: var(--surface-danger-emphasis);
color: var(--text-danger-strong);
}

View File

@@ -0,0 +1,61 @@
// Path: Frontend/src/components/dashboard/dashboard-activity.tsx
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import styles from "./dashboard-activity.module.scss";
import { activitySummary, highlightCards, progressLabels, progressPoints } from "./dashboard.data";
const points = progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" ");
const DashboardActivity: Component = () => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Your progress</h2>
<A href="/dashboard/progress">Open progress</A>
</div>
<article class={styles.progressCard}>
<div class={styles.progressHeader}>
<div>
<h3>{activitySummary.title}</h3>
<p>{activitySummary.note}</p>
</div>
<div class={styles.progressBadge}>{activitySummary.badge}</div>
</div>
<svg viewBox="0 0 270 140" class={styles.chart} role="img" aria-label="My progress line chart">
<defs>
<linearGradient id="progress-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--blue-300)" stop-opacity="0.55" />
<stop offset="100%" stop-color="var(--blue-300)" stop-opacity="0.05" />
</linearGradient>
</defs>
<line x1="0" y1="120" x2="270" y2="120" class={styles.axis} />
<polyline points={`0,120 ${points} 270,120`} class={styles.area} />
<polyline points={points} class={styles.line} />
</svg>
<div class={styles.monthRow}>
<For each={progressLabels}>{(label) => <span>{label}</span>}</For>
</div>
</article>
<div class={styles.highlightGrid}>
<For each={highlightCards}>
{(card) => (
<article classList={{ [styles.highlightCard]: true, [styles[card.tone]]: true }}>
<strong>{card.value}</strong>
<h3>{card.label}</h3>
<p>{card.note}</p>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardActivity;

View File

@@ -0,0 +1,245 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 52ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.25rem;
line-height: 1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.groupList {
display: grid;
gap: 1rem;
}
.group {
display: grid;
gap: 0.85rem;
}
.groupHeader {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.2rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
margin-top: 0.2rem;
font-size: 0.92rem;
color: var(--text-muted);
}
}
.groupCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 0.7rem;
border-radius: 9999px;
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-weight: 600;
font-size: 0.88rem;
}
.cardGrid {
display: grid;
gap: 0.85rem;
@media (min-width: 820px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.assignmentCard {
display: grid;
gap: 0.9rem;
padding: 1rem;
border-radius: 1.3rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.cardTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.statusChip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.68rem;
border-radius: 9999px;
font-size: 0.76rem;
font-weight: 600;
white-space: nowrap;
}
.blue {
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
}
.yellow {
background: var(--surface-warning-emphasis);
color: var(--text-warning-strong);
}
.teal {
background: var(--surface-success-emphasis);
color: var(--text-success-strong);
}
.progressText {
font-size: 0.82rem;
color: var(--text-muted);
white-space: nowrap;
}
.cardBody {
display: grid;
gap: 0.28rem;
h3 {
font-size: 1rem;
font-weight: 500;
}
p {
font-size: 0.92rem;
color: var(--text-muted);
}
small {
font-size: 0.82rem;
color: var(--text-muted);
}
}
.cardActions {
display: grid;
gap: 0.65rem;
@media (min-width: 520px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
font-weight: 500;
text-decoration: none;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}
.primaryAction {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
}
.secondaryAction {
background: var(--surface-soft);
color: var(--text);
border: 1px solid var(--border-soft);
}

View File

@@ -0,0 +1,76 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { assignmentFocusGroups, assignmentFocusStats } from "./dashboard.data";
import styles from "./dashboard-assignments-focus.module.scss";
const DashboardAssignmentsFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Assignments</p>
<h1>Your assignment hub</h1>
<p>Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.</p>
</div>
<div class={styles.statGrid}>
<For each={assignmentFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.groupList}>
<For each={assignmentFocusGroups}>
{(group) => (
<section class={styles.group}>
<div class={styles.groupHeader}>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<span class={styles.groupCount}>{group.items.length}</span>
</div>
<div class={styles.cardGrid}>
<For each={group.items}>
{(item) => (
<article class={styles.assignmentCard}>
<div class={styles.cardTop}>
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
<span class={styles.progressText}>{item.progressText}</span>
</div>
<div class={styles.cardBody}>
<h3>{item.title}</h3>
<p>{item.meta}</p>
<small>{item.subMeta}</small>
</div>
<div class={styles.cardActions}>
<A href={item.primaryHref} class={styles.primaryAction}>
{item.primaryLabel}
</A>
<A href={item.secondaryHref} class={styles.secondaryAction}>
{item.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
)}
</For>
</div>
</section>
);
};
export default DashboardAssignmentsFocus;

View File

@@ -0,0 +1,103 @@
.section {
display: grid;
gap: 1.05rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.25rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
a {
color: var(--primary);
font-weight: 500;
font-size: 0.9rem;
}
}
.courseList {
display: grid;
gap: 0.85rem;
}
.courseCard {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@media (min-width: 720px) {
grid-template-columns: auto 1fr auto;
align-items: center;
}
.ctaLink {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.68rem 0.95rem;
border-radius: 9999px;
background: var(--surface-info);
color: var(--info);
font-weight: 500;
font-size: 0.9rem;
transition:
transform 0.2s ease,
background-color 0.2s ease;
text-decoration: none;
&:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--surface-info) 70%, var(--blue-100));
text-decoration: none;
}
}
}
@media (min-width: 720px) {
.courseCard .ctaLink {
width: auto;
}
}
.badge {
width: 2.8rem;
height: 2.8rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
font-weight: 600;
}
.yellow { background: var(--yellow-200); color: var(--yellow-500); }
.pink { background: var(--red-100); color: var(--red-400); }
.teal { background: var(--teal-100); color: var(--teal-500); }
.blue { background: var(--blue-100); color: var(--blue-500); }
.courseBody {
display: grid;
gap: 0.25rem;
h3 {
font-size: 1rem;
font-weight: 500;
}
p {
font-size: 0.9rem;
color: var(--text-muted);
}
}

View File

@@ -0,0 +1,35 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { assignmentCards } from "./dashboard.data";
import styles from "./dashboard-courses.module.scss";
const DashboardCourses: Component = () => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Keep going</h2>
<A href="/dashboard/assignments">See all</A>
</div>
<div class={styles.courseList}>
<For each={assignmentCards}>
{(assignment) => (
<article class={styles.courseCard}>
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.title.split("—")[0].trim().replace("HW", "H")}</div>
<div class={styles.courseBody}>
<h3>{assignment.title}</h3>
<p>{assignment.lessons}</p>
</div>
<A href={assignment.href} class={styles.ctaLink}>
{assignment.cta}
</A>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardCourses;

View File

@@ -0,0 +1,261 @@
.heroGrid {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
grid-template-columns: minmax(0, 1.6fr) minmax(18rem, 0.75fr);
}
}
.heroCard,
.sideCard,
.statCard {
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
}
.heroCard {
display: grid;
gap: 1.25rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
color: var(--text-on-accent);
overflow: hidden;
@media (min-width: 640px) {
padding: 1.4rem;
}
@media (min-width: 960px) {
gap: 1.6rem;
padding: 1.75rem;
}
@media (min-width: 900px) {
grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr);
align-items: center;
}
}
.heroCopy {
display: grid;
gap: 0.95rem;
}
.heroCopy h1 {
max-width: 9ch;
font-size: clamp(1.85rem, 1.5rem + 1.4vw, 3.5rem);
line-height: 0.99;
letter-spacing: -0.04em;
}
.heroCopy p {
max-width: 40ch;
font-size: 0.94rem;
color: var(--text-on-accent-muted);
@media (min-width: 640px) {
font-size: 1rem;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-on-accent-muted);
}
.metricRow {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.4rem;
}
.metricPill {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.62rem 0.8rem;
border-radius: 9999px;
background: var(--surface-overlay-soft);
backdrop-filter: blur(8px);
border: 1px solid var(--border-overlay);
min-width: 0;
p,
strong {
color: var(--text-on-accent);
}
p {
font-size: 0.8rem;
}
strong {
font-size: 0.85rem;
font-weight: 600;
}
}
.metricIcon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: 9999px;
font-weight: 700;
color: var(--text-accent-strong);
}
.purple {
background: var(--surface-accent-emphasis);
color: var(--text-accent-strong);
}
.yellow {
background: var(--surface-warning-emphasis);
color: var(--text-warning-strong);
}
.blue {
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
}
.heroVisual {
position: relative;
min-height: 10.5rem;
display: grid;
place-items: center;
@media (min-width: 640px) {
min-height: 12rem;
}
@media (min-width: 960px) {
min-height: 13rem;
}
}
.orbit {
position: absolute;
inset: 1rem;
border-radius: 50%;
border: 1px dashed var(--border-overlay);
}
.visualCard {
position: absolute;
padding: 0.5rem 0.72rem;
border-radius: 9999px;
background: var(--surface-overlay-strong);
color: var(--text-on-accent);
font-weight: 600;
backdrop-filter: blur(10px);
font-size: 0.8rem;
&:nth-child(2) {
top: 18%;
left: 18%;
}
&:nth-child(3) {
right: 10%;
top: 42%;
}
&:nth-child(4) {
bottom: 15%;
left: 35%;
}
}
.sideCard {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.5rem;
background: var(--surface-panel);
@media (min-width: 640px) {
padding: 1.15rem;
}
@media (min-width: 960px) {
gap: 1rem;
padding: 1.35rem;
}
}
.sideCard h2 {
max-width: 12ch;
font-size: clamp(1.35rem, 1.12rem + 0.55vw, 1.8rem);
line-height: 1.08;
letter-spacing: -0.03em;
}
.sideCard p {
color: var(--text-muted);
font-size: 0.92rem;
}
.sideCardAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.82rem 0.95rem;
border: none;
border-radius: 1rem;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;
box-shadow: var(--action-primary-shadow);
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
text-decoration: none;
}
}
.quickStats {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.7rem;
@media (min-width: 480px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.45rem;
padding: 0.9rem;
border-radius: 1.2rem;
background: var(--surface-panel-strong);
border: 1px solid var(--border-soft);
p {
font-size: 0.85rem;
color: var(--text-muted);
}
strong {
font-size: 1.55rem;
line-height: 1;
font-weight: 600;
}
}

View File

@@ -0,0 +1,59 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { heroSideCard, heroSummary, quickStats, spotlightStats } from "./dashboard.data";
import styles from "./dashboard-hero.module.scss";
const DashboardHero: Component = () => {
return (
<section class={styles.heroGrid}>
<div class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{heroSummary.eyebrow}</p>
<h1>{heroSummary.title}</h1>
<p>{heroSummary.description}</p>
<div class={styles.metricRow}>
<For each={spotlightStats}>
{(stat) => (
<div class={styles.metricPill}>
<span classList={{ [styles.metricIcon]: true, [styles[stat.tone]]: true }}>{stat.label.slice(0, 1)}</span>
<div>
<p>{stat.label}</p>
<strong>{stat.value}</strong>
</div>
</div>
)}
</For>
</div>
</div>
<div class={styles.heroVisual} aria-hidden="true">
<div class={styles.orbit}></div>
<For each={heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
</div>
</div>
<div class={styles.sideCard}>
<h2>{heroSideCard.title}</h2>
<p>{heroSideCard.description}</p>
<A href={heroSideCard.buttonHref} class={styles.sideCardAction}>
{heroSideCard.buttonLabel}
</A>
<div class={styles.quickStats}>
<For each={quickStats}>
{(stat) => (
<div class={styles.statCard}>
<p>{stat.label}</p>
<strong>{stat.value}</strong>
</div>
)}
</For>
</div>
</div>
</section>
);
};
export default DashboardHero;

View File

@@ -0,0 +1,157 @@
.grid {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
grid-template-columns: minmax(0, 1.3fr) minmax(16rem, 0.8fr) minmax(16rem, 0.9fr);
}
}
.panel {
display: grid;
gap: 1rem;
padding: 1rem;
border-radius: 1.35rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@media (min-width: 640px) {
padding: 1.15rem;
}
@media (min-width: 960px) {
padding: 1.35rem;
}
h2 {
font-size: 1.2rem;
line-height: 1.12;
letter-spacing: -0.03em;
}
}
.barChart,
.usageList {
display: grid;
gap: 0.9rem;
}
.barRow,
.usageItem {
display: grid;
gap: 0.45rem;
}
.barMeta,
.usageMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
color: var(--text-muted);
flex-wrap: wrap;
}
.barMeta {
justify-content: flex-start;
span:last-child {
color: var(--text);
}
}
.dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
display: inline-block;
margin-right: 0.55rem;
}
.track {
height: 0.75rem;
border-radius: 9999px;
background: var(--surface-soft);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: inherit;
}
.blue { background: var(--blue-400); }
.purple { background: var(--purple-400); }
.teal { background: var(--teal-400); }
.pink { background: var(--red-300); }
.yellow { background: var(--yellow-300); }
.donutWrap {
display: grid;
place-items: center;
padding: 0.5rem 0;
position: relative;
min-height: 12rem;
@media (min-width: 640px) {
padding: 1rem 0;
min-height: 15rem;
}
}
.donut {
width: 9.5rem;
height: 9.5rem;
border-radius: 50%;
background: conic-gradient(var(--purple-400) 0 54%, var(--blue-400) 54% 74%, var(--surface-soft) 74% 100%);
position: relative;
@media (min-width: 640px) {
width: 12rem;
height: 12rem;
}
&::after {
content: "";
position: absolute;
inset: 1.45rem;
border-radius: 50%;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
@media (min-width: 640px) {
inset: 1.8rem;
}
}
}
.donutLabel {
position: absolute;
display: grid;
place-items: center;
gap: 0.15rem;
strong {
font-size: 1.6rem;
font-weight: 700;
@media (min-width: 640px) {
font-size: 2rem;
}
}
span {
color: var(--text-muted);
}
}
.usageNote {
color: var(--info);
font-weight: 500;
font-size: 0.92rem;
}

View File

@@ -0,0 +1,62 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { overallPassRate, topicMasteryBars, solveModeUsage, usageSummary } from "./dashboard.data";
import styles from "./dashboard-insights.module.scss";
const DashboardInsights: Component = () => {
return (
<section class={styles.grid}>
<article class={styles.panel}>
<h2>My topic mastery</h2>
<div class={styles.barChart}>
<For each={topicMasteryBars}>
{(item) => (
<div class={styles.barRow}>
<div class={styles.barMeta}>
<span classList={{ [styles.dot]: true, [styles[item.tone]]: true }}></span>
<span>{item.label}</span>
</div>
<div class={styles.track}>
<div classList={{ [styles.fill]: true, [styles[item.tone]]: true }} style={{ width: `${item.value}%` }}></div>
</div>
</div>
)}
</For>
</div>
</article>
<article class={styles.panel}>
<h2>My overall accuracy</h2>
<div class={styles.donutWrap}>
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, overallPassRate - 18)}% ${overallPassRate}%, var(--surface-soft) ${overallPassRate}% 100%)` }}></div>
<div class={styles.donutLabel}>
<strong>{overallPassRate}%</strong>
<span>Accuracy</span>
</div>
</div>
</article>
<article class={styles.panel}>
<h2>Solve mode usage</h2>
<p class={styles.usageNote}>{usageSummary.note}</p>
<div class={styles.usageList}>
<For each={solveModeUsage}>
{(item) => (
<div class={styles.usageItem}>
<div class={styles.usageMeta}>
<span>{item.label}</span>
<strong>{item.value}%</strong>
</div>
<div class={styles.track}>
<div classList={{ [styles.fill]: true, [styles[item.tone]]: true }} style={{ width: `${item.value}%` }}></div>
</div>
</div>
)}
</For>
</div>
</article>
</section>
);
};
export default DashboardInsights;

View File

@@ -0,0 +1,103 @@
.section {
display: grid;
gap: 1.05rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.25rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
a {
color: var(--primary);
font-weight: 500;
font-size: 0.9rem;
}
}
.list {
display: grid;
gap: 0.85rem;
}
.card {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@media (min-width: 720px) {
grid-template-columns: auto 1fr auto;
align-items: center;
}
.actionLink {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.7rem 0.95rem;
border: none;
border-radius: 9999px;
background: var(--surface-soft);
color: var(--text-muted);
font-weight: 500;
font-size: 0.88rem;
cursor: pointer;
transition:
transform 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: var(--surface-info);
color: var(--info);
text-decoration: none;
}
}
}
@media (min-width: 720px) {
.card .actionLink {
width: auto;
}
}
.avatar {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
background: linear-gradient(135deg, var(--surface-accent-soft), var(--surface-info));
color: var(--primary);
font-weight: 600;
}
.copy {
display: grid;
gap: 0.2rem;
h3 {
font-size: 0.98rem;
font-weight: 500;
}
p {
font-size: 0.9rem;
color: var(--text-muted);
}
}

View File

@@ -0,0 +1,37 @@
// Path: Frontend/src/components/dashboard/dashboard-instructors.tsx
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { For } from "solid-js";
import styles from "./dashboard-instructors.module.scss";
import { studentSupportList } from "./dashboard.data";
const DashboardInstructors: Component = () => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Try these next</h2>
<A href="/dashboard/practice">View plan</A>
</div>
<div class={styles.list}>
<For each={studentSupportList}>
{(student) => (
<article class={styles.card}>
<div class={styles.avatar}>{student.initials}</div>
<div class={styles.copy}>
<h3>{student.name}</h3>
<p>{student.meta}</p>
</div>
<A href={student.href} class={styles.actionLink}>
{student.actionLabel}
</A>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardInstructors;

View File

@@ -0,0 +1,188 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 58ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.25rem;
line-height: 1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.threadList {
display: grid;
gap: 1rem;
@media (min-width: 900px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.threadCard {
display: grid;
gap: 0.95rem;
padding: 1rem;
border-radius: 1.3rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.threadTop {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.85rem;
flex-wrap: wrap;
}
.senderWrap {
display: flex;
align-items: center;
gap: 0.8rem;
h2 {
font-size: 1rem;
line-height: 1.1;
}
p {
margin-top: 0.18rem;
font-size: 0.86rem;
color: var(--text-muted);
}
}
.avatar {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 9999px;
background: var(--surface-accent-soft);
color: var(--primary);
font-weight: 600;
flex-shrink: 0;
}
.threadMeta {
display: grid;
justify-items: end;
gap: 0.32rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.unread {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.32rem 0.62rem;
border-radius: 9999px;
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-size: 0.74rem;
font-weight: 600;
}
.preview {
font-size: 0.93rem;
color: var(--text-muted);
}
.threadActions {
display: flex;
justify-content: flex-start;
}
.primaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
font-weight: 500;
text-decoration: none;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}

View File

@@ -0,0 +1,62 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { messageFocusStats, messageFocusThreads } from "./dashboard.data";
import styles from "./dashboard-messages-focus.module.scss";
const DashboardMessagesFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Messages</p>
<h1>Your message centre</h1>
<p>Keep dashboard context while you check tutor guidance, assignment reminders, and quick study nudges that point you to the next useful action.</p>
</div>
<div class={styles.statGrid}>
<For each={messageFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.threadList}>
<For each={messageFocusThreads}>
{(thread) => (
<article class={styles.threadCard}>
<div class={styles.threadTop}>
<div class={styles.senderWrap}>
<span class={styles.avatar}>{thread.initials}</span>
<div>
<h2>{thread.sender}</h2>
<p>{thread.role}</p>
</div>
</div>
<div class={styles.threadMeta}>
<span>{thread.timestamp}</span>
{thread.unread && <strong class={styles.unread}>Unread</strong>}
</div>
</div>
<p class={styles.preview}>{thread.preview}</p>
<div class={styles.threadActions}>
<A href={thread.actionHref} class={styles.primaryAction}>
{thread.actionLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardMessagesFocus;

View File

@@ -0,0 +1,185 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 58ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.25rem;
line-height: 1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.cardGrid {
display: grid;
gap: 1rem;
@media (min-width: 820px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.practiceCard {
display: grid;
gap: 0.95rem;
padding: 1rem;
border-radius: 1.3rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.toneChip {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
max-width: 100%;
padding: 0.42rem 0.72rem;
border-radius: 9999px;
font-size: 0.76rem;
font-weight: 600;
line-height: 1.2;
}
.yellow {
background: var(--surface-warning-emphasis);
color: var(--text-warning-strong);
}
.blue {
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
}
.teal {
background: var(--surface-success-emphasis);
color: var(--text-success-strong);
}
.cardBody {
display: grid;
gap: 0.38rem;
h2 {
font-size: 1.05rem;
line-height: 1.12;
letter-spacing: -0.03em;
}
p {
font-size: 0.92rem;
color: var(--text-muted);
}
}
.cardActions {
display: grid;
gap: 0.65rem;
@media (min-width: 520px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
font-weight: 500;
text-decoration: none;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}
.primaryAction {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
}
.secondaryAction {
background: var(--surface-soft);
color: var(--text);
border: 1px solid var(--border-soft);
}

View File

@@ -0,0 +1,56 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { practiceFocusCards, practiceFocusStats } from "./dashboard.data";
import styles from "./dashboard-practice-focus.module.scss";
const DashboardPracticeFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Practice</p>
<h1>Your practice space</h1>
<p>Stay in dashboard mode to focus on the skill that needs the most attention, choose the right support mode, and jump into the next useful question set.</p>
</div>
<div class={styles.statGrid}>
<For each={practiceFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.cardGrid}>
<For each={practiceFocusCards}>
{(card) => (
<article class={styles.practiceCard}>
<span classList={{ [styles.toneChip]: true, [styles[card.tone]]: true }}>{card.meta}</span>
<div class={styles.cardBody}>
<h2>{card.title}</h2>
<p>{card.description}</p>
</div>
<div class={styles.cardActions}>
<A href={card.primaryHref} class={styles.primaryAction}>
{card.primaryLabel}
</A>
<A href={card.secondaryHref} class={styles.secondaryAction}>
{card.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardPracticeFocus;

View File

@@ -0,0 +1,90 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 58ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.25rem;
line-height: 1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.contentStack {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}

View File

@@ -0,0 +1,38 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import DashboardActivity from "./dashboard-activity";
import DashboardInsights from "./dashboard-insights";
import { progressFocusStats } from "./dashboard.data";
import styles from "./dashboard-progress-focus.module.scss";
const DashboardProgressFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Progress</p>
<h1>Your learning progress</h1>
<p>Stay inside the dashboard to review your results, track improvement, and spot where a small practice block could lift your score fastest.</p>
</div>
<div class={styles.statGrid}>
<For each={progressFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.contentStack}>
<DashboardActivity />
<DashboardInsights />
</div>
</section>
);
};
export default DashboardProgressFocus;

View File

@@ -0,0 +1,223 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr);
align-items: start;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 58ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.profileChip {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
display: block;
font-size: 1rem;
}
span:last-child {
color: var(--text-muted);
font-size: 0.88rem;
}
}
.avatar {
display: inline-grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;
flex-shrink: 0;
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-column: 1 / -1;
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.1rem;
line-height: 1.1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.panelGrid {
display: grid;
gap: 1rem;
@media (min-width: 1040px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
}
}
.panelCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.35rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.panelBody {
display: grid;
gap: 1rem;
}
.panelCopy {
display: grid;
gap: 0.35rem;
h2 {
font-size: 1.2rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
font-size: 0.92rem;
color: var(--text-muted);
}
}
.rowList {
display: grid;
gap: 0.65rem;
}
.rowItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 1rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
span {
font-size: 0.86rem;
color: var(--text-muted);
}
strong {
font-size: 0.92rem;
text-align: right;
}
}
.panelActions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
font-weight: 500;
text-decoration: none;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
.primaryAction {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
}
}
.secondaryAction {
background: var(--surface-soft);
color: var(--text);
border: 1px solid var(--border-soft);
&:hover {
transform: translateY(-1px);
background: var(--surface-panel-strong);
}
}

View File

@@ -0,0 +1,77 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { settingsFocusPanels, settingsFocusStats, topbarSummary } from "./dashboard.data";
import styles from "./dashboard-settings-focus.module.scss";
const DashboardSettingsFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Settings</p>
<h1>Your dashboard settings</h1>
<p>Keep this inside the dashboard shell so profile details, study preferences, and learner goals feel like part of the same student workspace.</p>
</div>
<div class={styles.profileChip}>
<span class={styles.avatar}>{topbarSummary.profileBadge}</span>
<div>
<strong>{topbarSummary.profileName}</strong>
<span>{topbarSummary.profileRole}</span>
</div>
</div>
<div class={styles.statGrid}>
<For each={settingsFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.panelGrid}>
<For each={settingsFocusPanels}>
{(panel) => (
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>{panel.title}</h2>
<p>{panel.description}</p>
</div>
<div class={styles.rowList}>
<For each={panel.rows}>
{(row) => (
<div class={styles.rowItem}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
)}
</For>
</div>
</div>
<div class={styles.panelActions}>
<A href={panel.primaryHref} class={styles.primaryAction}>
{panel.primaryLabel}
</A>
{panel.secondaryHref && panel.secondaryLabel && (
<A href={panel.secondaryHref} class={styles.secondaryAction}>
{panel.secondaryLabel}
</A>
)}
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardSettingsFocus;

View File

@@ -0,0 +1,81 @@
import { createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js";
import DashboardSidebar from "./dashboard-sidebar";
import DashboardTopbar from "./dashboard-topbar";
import styles from "../../routes/dashboard/dashboard.module.scss";
type DashboardShellProps = {
children: JSX.Element;
};
const DashboardShell: Component<DashboardShellProps> = (props) => {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = createSignal(false);
const closeSidebar = () => setSidebarOpen(false);
const isDesktopViewport = () => typeof window !== "undefined" && window.innerWidth >= 1024;
const isSidebarVisible = () => (isDesktopViewport() ? !desktopSidebarCollapsed() : sidebarOpen());
const toggleSidebar = () => {
if (isDesktopViewport()) {
setDesktopSidebarCollapsed((collapsed) => !collapsed);
return;
}
setSidebarOpen((open) => !open);
};
onMount(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) {
setSidebarOpen(false);
}
};
window.addEventListener("resize", handleResize);
onCleanup(() => window.removeEventListener("resize", handleResize));
});
createEffect(() => {
if (typeof document === "undefined") return;
const shouldLockScroll = sidebarOpen() && window.innerWidth < 1024;
document.body.style.overflow = shouldLockScroll ? "hidden" : "";
onCleanup(() => {
document.body.style.overflow = "";
});
});
return (
<main class={styles.dashboardPage}>
<div classList={{ [styles.dashboardLayout]: true, [styles.dashboardLayoutSidebarCollapsed]: desktopSidebarCollapsed() }}>
<div class={styles.sidebarRail}>
<button
type="button"
classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }}
aria-label="Close sidebar"
tabIndex={sidebarOpen() ? 0 : -1}
onClick={closeSidebar}
/>
<div
classList={{
[styles.sidebarPanel]: true,
[styles.sidebarPanelOpen]: sidebarOpen(),
[styles.sidebarPanelDesktopCollapsed]: desktopSidebarCollapsed(),
}}
>
<div class={styles.sidebarPanelInner}>
<DashboardSidebar onNavigate={closeSidebar} />
</div>
</div>
</div>
<section class={styles.dashboardMain}>
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} />
{props.children}
</section>
</div>
</main>
);
};
export default DashboardShell;

View File

@@ -0,0 +1,238 @@
.sidebar {
display: grid;
gap: 1rem;
padding: 1rem;
background: var(--surface-panel);
border: 1px solid var(--border-strong);
border-radius: 1.5rem;
box-shadow: var(--shadow-soft);
@media (min-width: 768px) {
padding: 1.15rem;
}
@media (min-width: 1024px) {
gap: 1.25rem;
padding: 1.3rem;
position: sticky;
top: 1rem;
}
}
.brand {
display: flex;
align-items: center;
gap: 0.85rem;
}
.logoMark {
width: 2.6rem;
height: 2.6rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
background: linear-gradient(135deg, var(--action-primary-start), var(--info));
color: var(--action-primary-text);
font-weight: 600;
box-shadow: var(--action-primary-shadow);
}
.brandName {
font-weight: 600;
font-size: 1rem;
}
.brandMeta {
color: var(--text-muted);
font-size: 0.9rem;
}
.navigation {
display: grid;
gap: 0.35rem;
@media (max-width: 519px) {
display: flex;
overflow-x: auto;
gap: 0.55rem;
padding-bottom: 0.1rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (min-width: 520px) and (max-width: 1023px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.link {
display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 0.78rem 0.85rem;
border-radius: 1rem;
color: var(--text-muted);
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover {
text-decoration: none;
background: var(--surface-soft);
color: var(--text);
transform: translateX(2px);
}
}
@media (max-width: 519px) {
.link {
flex: 0 0 auto;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.82rem;
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
white-space: nowrap;
}
}
.linkCopy {
display: grid;
gap: 0.08rem;
min-width: 0;
strong {
color: var(--text);
font-size: 0.94rem;
font-weight: 500;
line-height: 1.15;
}
small {
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.2;
}
}
@media (max-width: 519px) {
.linkCopy {
gap: 0;
strong {
font-size: 0.9rem;
}
small {
display: none;
}
}
}
.active {
background: var(--surface-info);
color: var(--info);
.iconSlot {
background: var(--blue-100);
border-color: color-mix(in srgb, var(--info) 18%, var(--border-divider));
color: var(--info);
}
.linkCopy {
strong,
small {
color: var(--info);
}
}
}
.iconSlot {
width: 1.9rem;
height: 1.9rem;
display: grid;
place-items: center;
border-radius: 0.65rem;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
font-size: 0.8rem;
font-weight: 600;
flex-shrink: 0;
}
.supportCard {
display: grid;
gap: 0.8rem;
margin-top: 0.25rem;
padding: 1rem;
background: linear-gradient(180deg, var(--surface-panel-strong), var(--surface-soft));
border: 1px solid var(--border-soft);
border-radius: 1.25rem;
}
@media (max-width: 519px) {
.supportCard {
gap: 0.7rem;
padding: 0.9rem;
p {
font-size: 0.86rem;
}
}
}
.avatarRow {
display: flex;
align-items: center;
span {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: 9999px;
border: 2px solid var(--surface-panel);
background: var(--surface-accent-soft);
color: var(--primary);
font-size: 0.85rem;
font-weight: 600;
&:not(:first-child) {
margin-left: -0.4rem;
}
}
}
.supportCard h2 {
font-size: 1.05rem;
font-weight: 500;
line-height: 1.15;
}
.supportCard p {
color: var(--text-muted);
font-size: 0.92rem;
}
.supportButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border: none;
border-radius: 9999px;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
cursor: pointer;
font-weight: 600;
white-space: nowrap;
&:hover {
text-decoration: none;
}
}

View File

@@ -0,0 +1,62 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data";
import styles from "./dashboard-sidebar.module.scss";
type DashboardSidebarProps = {
onNavigate?: () => void;
};
const DashboardSidebar: Component<DashboardSidebarProps> = (props) => {
const location = useLocation();
const isActiveLink = (href?: string) => {
if (!href) return false;
if (href === "/dashboard") return location.pathname === "/dashboard";
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
return (
<aside class={styles.sidebar}>
<div class={styles.brand}>
<div class={styles.logoMark}>R</div>
<div>
<p class={styles.brandName}>Rooster AI</p>
<p class={styles.brandMeta}>{classroomSummary.name}</p>
</div>
</div>
<nav class={styles.navigation} aria-label="Dashboard navigation">
<For each={sidebarLinks}>
{(link) => (
<A
href={link.href ?? "#"}
classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && location.pathname === "/dashboard") }}
onClick={() => props.onNavigate?.()}
>
<span class={styles.iconSlot}>{link.icon}</span>
<span class={styles.linkCopy}>
<strong>{link.label}</strong>
{link.detail && <small>{link.detail}</small>}
</span>
</A>
)}
</For>
</nav>
<div class={styles.supportCard}>
<div class={styles.avatarRow}>
<For each={sidebarSupport.avatars}>{(avatar) => <span>{avatar}</span>}</For>
</div>
<h2>{sidebarSupport.title}</h2>
<p>{sidebarSupport.description}</p>
<A href={sidebarSupport.buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
{sidebarSupport.buttonLabel}
</A>
</div>
</aside>
);
};
export default DashboardSidebar;

View File

@@ -0,0 +1,75 @@
.toggleButton {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border-radius: 1rem;
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
box-shadow: var(--shadow-soft);
cursor: pointer;
transition:
transform 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: var(--surface-panel-strong);
border-color: var(--border-strong);
}
@media (min-width: 768px) {
width: 3rem;
height: 3rem;
}
}
.iconContainer {
position: relative;
width: 1.2rem;
height: 1.2rem;
}
.colorIcon {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition:
transform 700ms ease,
opacity 300ms ease;
svg {
width: 1.2rem;
height: 1.2rem;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.moonWrapper {
transform: rotate(90deg);
opacity: 0;
:global([data-color-scheme="dark"]) & {
transform: rotate(0deg);
opacity: 1;
}
}
.sunWrapper {
transform: rotate(0deg);
opacity: 1;
:global([data-color-scheme="dark"]) & {
transform: rotate(-90deg);
opacity: 0;
}
}

View File

@@ -0,0 +1,27 @@
import type { Component } from "solid-js";
import { useTheme } from "../../context/theme/context";
import styles from "./dashboard-theme-toggle.module.scss";
const DashboardThemeToggle: Component = () => {
const { toggleMode } = useTheme();
return (
<button type="button" aria-label="Toggle colour scheme" class={styles.toggleButton} onClick={toggleMode}>
<div class={styles.iconContainer}>
<span class={`${styles.colorIcon} ${styles.moonWrapper}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15.5 3.5A7.5 7.5 0 1 0 20.5 16a8 8 0 1 1-5-12.5Z" />
</svg>
</span>
<span class={`${styles.colorIcon} ${styles.sunWrapper}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.7 4.7l1.6 1.6M17.7 17.7l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.7 19.3l1.6-1.6M17.7 6.3l1.6-1.6" />
</svg>
</span>
</div>
</button>
);
};
export default DashboardThemeToggle;

View File

@@ -0,0 +1,451 @@
.topbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
position: relative;
z-index: 50;
@media (max-width: 519px) {
grid-template-columns: auto minmax(0, 1fr);
}
@media (min-width: 880px) {
grid-template-columns: auto minmax(0, 1fr) auto;
}
}
.menuButton,
.iconButton {
width: 2.75rem;
height: 2.75rem;
display: grid;
place-items: center;
border-radius: 1rem;
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
box-shadow: var(--shadow-soft);
cursor: pointer;
transition:
transform 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: var(--surface-panel-strong);
border-color: var(--border-strong);
}
svg {
width: 1.15rem;
height: 1.15rem;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.menuButton {
position: relative;
z-index: 70;
}
.menuGlyph {
position: relative;
width: 1.1rem;
height: 0.95rem;
display: inline-block;
}
.menuLine {
position: absolute;
left: 0;
width: 100%;
height: 2px;
border-radius: 9999px;
background: currentColor;
transform-origin: center;
transition:
transform 0.38s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.24s var(--transition-ease),
top 0.38s cubic-bezier(0.22, 1, 0.36, 1),
background-color 0.2s var(--transition-ease);
&:nth-child(1) {
top: 0.05rem;
}
&:nth-child(2) {
top: 0.41rem;
}
&:nth-child(3) {
top: 0.77rem;
}
}
.menuButtonOpen {
.menuLine {
&:nth-child(1) {
top: 0.41rem;
transform: rotate(45deg);
}
&:nth-child(2) {
opacity: 0;
transform: scaleX(0.4);
}
&:nth-child(3) {
top: 0.41rem;
transform: rotate(-45deg);
}
}
}
@media (min-width: 768px) {
.menuButton,
.iconButton {
width: 3rem;
height: 3rem;
}
}
.searchField {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0 0.85rem;
border-radius: 1rem;
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
box-shadow: var(--shadow-soft);
input {
width: 100%;
padding: 0.82rem 0;
border: none;
background: transparent;
outline: none;
color: var(--text);
font-size: 0.9rem;
}
}
@media (min-width: 768px) {
.searchField {
padding: 0 1rem;
input {
padding: 0.95rem 0;
font-size: 0.95rem;
}
}
}
.searchIcon {
display: grid;
place-items: center;
color: var(--text-muted);
svg {
width: 1rem;
height: 1rem;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
grid-column: 1 / -1;
overflow-x: auto;
padding-bottom: 0.15rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@media (min-width: 880px) {
grid-column: auto;
justify-content: flex-end;
overflow: visible;
padding-bottom: 0;
}
}
.menuGroup {
position: relative;
flex: 0 0 auto;
}
.countBadge {
position: absolute;
top: -0.15rem;
right: -0.15rem;
min-width: 1.1rem;
height: 1.1rem;
display: grid;
place-items: center;
padding: 0 0.2rem;
border-radius: 9999px;
background: var(--primary);
color: var(--action-primary-text);
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
box-shadow: var(--shadow-soft);
}
.dropdown {
position: absolute;
top: calc(100% + 0.6rem);
right: 0;
width: min(22rem, calc(100vw - 2rem));
display: grid;
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
border: 1px solid var(--border-strong);
background: var(--surface-raised);
box-shadow: var(--shadow-elevated);
backdrop-filter: blur(18px);
z-index: 20;
}
.dropdownHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
strong {
font-size: 1rem;
line-height: 1.1;
}
}
.dropdownEyebrow {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.12rem;
}
.dropdownLink {
color: var(--primary);
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.dropdownList {
display: grid;
gap: 0.65rem;
}
.dropdownItem {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 0.7rem;
padding: 0.8rem;
border-radius: 1rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
background: var(--surface-panel-strong);
text-decoration: none;
}
small {
color: var(--text-muted);
font-size: 0.74rem;
white-space: nowrap;
}
}
.itemCopy {
min-width: 0;
display: grid;
gap: 0.2rem;
strong {
font-size: 0.92rem;
line-height: 1.15;
}
p {
font-size: 0.82rem;
line-height: 1.35;
color: var(--text-muted);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.itemTone,
.messageAvatar {
width: 2.15rem;
height: 2.15rem;
border-radius: 9999px;
flex-shrink: 0;
}
.itemTone {
display: inline-block;
margin-top: 0.1rem;
border: 1px solid var(--border-soft);
}
.tone-blue {
background: var(--surface-info-emphasis);
}
.tone-yellow {
background: var(--surface-warning-emphasis);
}
.tone-teal {
background: var(--surface-success-emphasis);
}
.messageAvatar {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--surface-accent-emphasis), var(--surface-info-emphasis));
color: var(--text-accent-strong);
font-size: 0.8rem;
font-weight: 700;
margin-top: 0.1rem;
}
.profile {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.5rem 0.4rem 0.8rem;
border-radius: 9999px;
border: 1px solid var(--border-soft);
background: var(--surface-panel);
box-shadow: var(--shadow-soft);
white-space: nowrap;
}
.profileButton {
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
&:hover {
text-decoration: none;
}
.profile {
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
}
&:hover .profile,
&:focus-visible .profile,
&.profileButtonOpen .profile {
transform: translateY(-1px);
background: var(--surface-panel-strong);
border-color: var(--border-strong);
}
}
.profileChevron {
display: grid;
place-items: center;
color: var(--text-muted);
transition: transform 0.24s var(--transition-ease);
svg {
width: 0.95rem;
height: 0.95rem;
stroke: currentColor;
fill: none;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.profileButtonOpen .profileChevron {
transform: rotate(180deg);
}
@media (min-width: 768px) {
.profile {
padding: 0.45rem 0.55rem 0.45rem 0.95rem;
}
}
.profileName {
font-weight: 500;
font-size: 0.9rem;
}
.profileRole {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@media (max-width: 519px) {
.profileRole {
display: none;
}
}
.profileBadge {
width: 2.2rem;
height: 2.2rem;
display: grid;
place-items: center;
border-radius: 9999px;
background: var(--surface-accent-emphasis);
color: var(--text-accent-strong);
font-weight: 600;
}
.profileMenuBadge {
width: 2.2rem;
height: 2.2rem;
display: grid;
place-items: center;
border-radius: 9999px;
background: var(--surface-accent-emphasis);
color: var(--text-accent-strong);
font-size: 0.9rem;
font-weight: 700;
flex-shrink: 0;
}

View File

@@ -0,0 +1,218 @@
import { A } from "@solidjs/router";
import { createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js";
import DashboardThemeToggle from "./dashboard-theme-toggle";
import { topbarMessages, topbarNotifications, topbarSummary } from "./dashboard.data";
import styles from "./dashboard-topbar.module.scss";
type DashboardTopbarProps = {
isSidebarOpen: boolean;
onMenuToggle: () => void;
};
const DashboardTopbar: Component<DashboardTopbarProps> = (props) => {
const [openMenu, setOpenMenu] = createSignal<"notifications" | "messages" | "profile" | null>(null);
let menuRoot: HTMLDivElement | undefined;
const toggleMenu = (menu: "notifications" | "messages" | "profile") => {
setOpenMenu((current) => (current === menu ? null : menu));
};
onMount(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!menuRoot?.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
return (
<header class={styles.topbar}>
<button
type="button"
classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }}
aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"}
aria-expanded={props.isSidebarOpen}
onClick={props.onMenuToggle}
>
<span class={styles.menuGlyph} aria-hidden="true">
<span class={styles.menuLine} />
<span class={styles.menuLine} />
<span class={styles.menuLine} />
</span>
</button>
<label class={styles.searchField}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input type="search" placeholder={topbarSummary.searchPlaceholder} />
</label>
<div class={styles.actions} ref={menuRoot}>
<DashboardThemeToggle />
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Notifications"
aria-expanded={openMenu() === "notifications"}
onClick={() => toggleMenu("notifications")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5a4 4 0 0 1 4 4v2.5c0 1 .4 2 .99 2.8L18 16H6l1.01-1.7c.59-.8.99-1.8.99-2.8V9a4 4 0 0 1 4-4Z" />
<path d="M10 18a2 2 0 0 0 4 0" />
</svg>
<Show when={topbarSummary.notificationCount > 0}>
<span class={styles.countBadge}>{topbarSummary.notificationCount}</span>
</Show>
</button>
<Show when={openMenu() === "notifications"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Alerts</p>
<strong>Notifications</strong>
</div>
<A href="/dashboard/progress" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open progress
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarNotifications}>
{(item) => (
<A href={item.href} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles[`tone-${item.tone}`]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Messages"
aria-expanded={openMenu() === "messages"}
onClick={() => toggleMenu("messages")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16v10H8l-4 3V6Z" />
</svg>
<Show when={topbarSummary.messageCount > 0}>
<span class={styles.countBadge}>{topbarSummary.messageCount}</span>
</Show>
</button>
<Show when={openMenu() === "messages"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Inbox</p>
<strong>Messages</strong>
</div>
<A href="/dashboard/messages" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open inbox
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarMessages}>
{(item) => (
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={styles.messageAvatar}>{item.initials}</span>
<div class={styles.itemCopy}>
<strong>{item.sender}</strong>
<p>{item.preview}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }}
aria-label="Profile menu"
aria-expanded={openMenu() === "profile"}
onClick={() => toggleMenu("profile")}
>
<div class={styles.profile}>
<div>
<p class={styles.profileName}>{topbarSummary.profileName}</p>
<p class={styles.profileRole}>{topbarSummary.profileRole}</p>
</div>
<span class={styles.profileBadge}>{topbarSummary.profileBadge}</span>
<span class={styles.profileChevron} aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="m5 7.5 5 5 5-5" />
</svg>
</span>
</div>
</button>
<Show when={openMenu() === "profile"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Signed in as</p>
<strong>{topbarSummary.profileName}</strong>
</div>
<span class={styles.profileMenuBadge}>{topbarSummary.profileBadge}</span>
</div>
<div class={styles.dropdownList}>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Profile</strong>
<p>Update your learner details and view your profile settings.</p>
</div>
</A>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Settings</strong>
<p>Manage goals, preferences, reminders, and dashboard options.</p>
</div>
</A>
<A href="/auth/login" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Log out</strong>
<p>Return to the sign-in screen. This is a UI-only sign out for now.</p>
</div>
</A>
</div>
</div>
</Show>
</div>
</div>
</header>
);
};
export default DashboardTopbar;

View File

@@ -0,0 +1,774 @@
import rawDataset from "../../../../Mock-Data/dataset.json";
type SidebarLink = {
label: string;
detail?: string;
icon: string;
href?: string;
active?: boolean;
};
type SpotlightStat = {
label: string;
value: string;
tone: "purple" | "yellow" | "blue";
};
type QuickStat = {
label: string;
value: string;
};
type AssignmentCard = {
title: string;
lessons: string;
accent: "yellow" | "pink" | "teal" | "blue";
cta: string;
href: string;
};
type AssignmentFocusStat = {
label: string;
value: string;
};
type AssignmentFocusItem = {
title: string;
meta: string;
subMeta: string;
statusLabel: string;
progressText: string;
tone: "yellow" | "teal" | "blue";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
type AssignmentFocusGroup = {
title: string;
description: string;
items: AssignmentFocusItem[];
};
type PracticeFocusStat = {
label: string;
value: string;
};
type PracticeFocusCard = {
title: string;
description: string;
meta: string;
tone: "yellow" | "teal" | "blue";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
type MessageFocusStat = {
label: string;
value: string;
};
type MessageFocusThread = {
sender: string;
role: string;
initials: string;
preview: string;
timestamp: string;
unread?: boolean;
actionLabel: string;
actionHref: string;
};
type TopbarNotificationItem = {
title: string;
description: string;
timestamp: string;
href: string;
tone: "blue" | "yellow" | "teal";
};
type SettingsFocusStat = {
label: string;
value: string;
};
type SettingsFocusPanel = {
title: string;
description: string;
rows: Array<{
label: string;
value: string;
}>;
primaryLabel: string;
primaryHref: string;
secondaryLabel?: string;
secondaryHref?: string;
};
type StudentSupportCard = {
name: string;
meta: string;
initials: string;
actionLabel: string;
href: string;
};
type HighlightCard = {
value: string;
label: string;
note: string;
tone: "yellow" | "pink";
};
type PerformanceBar = {
label: string;
value: number;
tone: "blue" | "purple" | "teal" | "pink" | "yellow";
};
type UsageItem = {
label: string;
value: number;
tone: "blue" | "purple" | "teal" | "yellow";
};
type Dataset = {
_meta: {
reference_today: string;
students: number;
assignments: number;
questions_in_bank: number;
student_answers: number;
expected_top_3_at_risk_student_ids: number[];
};
classroom: {
name: string;
invite_code: string;
target_level: number;
};
tutor: {
fullname: string;
role: string;
};
students: Array<{
id: number;
fullname: string;
_persona: string;
}>;
assignments: Array<{
id: number;
name: string;
topic: string;
status: "DRAFT" | "PUBLISHED" | "CLOSED";
due_date: number;
maximum_marks: number;
}>;
assignment_assignees: Array<{
id: number;
assignment_id: number;
student_id: number;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
total_marks: number;
}>;
student_answers: Array<{
assignee_id: number;
_is_correct: boolean;
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
_question_topic: string;
_answered_at: number;
}>;
activity_logs: Array<{
timestamp: number;
duration_seconds: number;
_student_id: number;
}>;
};
type StudentAssignment = Dataset["assignment_assignees"][number] & {
assignment: Dataset["assignments"][number];
answerCount: number;
accuracy: number;
};
const dataset = rawDataset as Dataset;
const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime();
const referenceDate = new Date(referenceTime);
const msPerDay = 1000 * 60 * 60 * 24;
const defaultStudentId = 201;
const students = dataset.students;
const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date);
const assignees = dataset.assignment_assignees;
const answers = dataset.student_answers;
const activityLogs = dataset.activity_logs;
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
const initialsFor = (name: string) =>
name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
const firstName = student.fullname.split(" ")[0];
const formatCompactNumber = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value);
const formatPercent = (value: number) => `${Math.round(value)}%`;
const formatDueDate = (timestamp: number) =>
new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(timestamp));
const formatHours = (seconds: number) => `${(seconds / 3600).toFixed(1)}h`;
const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay));
const formatLastSeen = (timestamp: number) => {
const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay));
if (dayDiff <= 0) return "today";
if (dayDiff === 1) return "yesterday";
return `${dayDiff} days ago`;
};
const topicTone = ["pink", "yellow", "blue", "purple", "teal"] as const;
const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee]));
const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id);
const studentLogs = activityLogs.filter((log) => log._student_id === student.id);
const studentAssignments: StudentAssignment[] = assignees
.filter((assignee) => assignee.student_id === student.id)
.map((assignee) => {
const assignment = assignmentById.get(assignee.assignment_id);
if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`);
const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id);
const answerCount = assignmentAnswers.length;
const correct = assignmentAnswers.filter((answer) => answer._is_correct).length;
return {
...assignee,
assignment,
answerCount,
accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0,
};
})
.sort((left, right) => left.assignment.due_date - right.assignment.due_date);
const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length;
const overallAccuracy = studentAnswers.length > 0 ? (totalCorrectAnswers / studentAnswers.length) * 100 : 0;
const totalDurationSeconds = studentLogs.reduce((sum, log) => sum + log.duration_seconds, 0);
const latestActivityTime = Math.max(...studentLogs.map((log) => log.timestamp));
const submittedAssignments = studentAssignments.filter((assignment) => assignment.status === "SUBMITTED");
const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS");
const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED");
const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED");
const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1];
const nextNotStartedAssignment = notStartedAssignments[0];
const topicBuckets = new Map<string, { correct: number; total: number }>();
for (const answer of studentAnswers) {
const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 };
bucket.correct += Number(answer._is_correct);
bucket.total += 1;
topicBuckets.set(answer._question_topic, bucket);
}
const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({
label,
value: Math.round((bucket.correct / bucket.total) * 100),
total: bucket.total,
}));
const strongestTopic = [...topicPerformance].sort((left, right) => right.value - left.value || right.total - left.total)[0];
const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0];
const topicMasteryBars: PerformanceBar[] = [...topicPerformance]
.sort((left, right) => right.total - left.total || left.value - right.value)
.slice(0, 5)
.sort((left, right) => left.value - right.value)
.map((topic, index) => ({
label: topic.label,
value: topic.value,
tone: topicTone[index % topicTone.length],
}));
const solveModeBuckets = new Map<string, number>();
for (const answer of studentAnswers) {
solveModeBuckets.set(answer._solve_mode, (solveModeBuckets.get(answer._solve_mode) ?? 0) + 1);
}
const solveModeToneMap: Record<string, UsageItem["tone"]> = {
just_answer: "purple",
step_by_step: "blue",
solve_together: "teal",
handwritten: "yellow",
};
const solveModeLabelMap: Record<string, string> = {
just_answer: "Just answer",
step_by_step: "Step by step",
solve_together: "Solve together",
handwritten: "Handwritten",
};
const solveModeUsage: UsageItem[] = [...solveModeBuckets.entries()]
.map(([mode, count]) => ({
label: solveModeLabelMap[mode] ?? mode,
value: Math.round((count / studentAnswers.length) * 100),
tone: solveModeToneMap[mode] ?? "blue",
}))
.sort((left, right) => right.value - left.value);
const topSolveMode = solveModeUsage[0];
const personaSummary: Record<string, { title: string; recommendation: string }> = {
fraction_inversion: {
title: "Fractions is your focus this week",
recommendation: "Slow down on equivalent fractions and practise inversion rules before test prep.",
},
place_value_gaps: {
title: "Place value is worth another pass",
recommendation: "Use quick warm-ups before multi-step arithmetic to avoid slips early in the question.",
},
rushed_careless: {
title: "A calmer pace will lift your accuracy",
recommendation: "Use Step by step more often on tougher questions so careless mistakes do not snowball.",
},
solve_together_dependent: {
title: "Independent confidence is your next goal",
recommendation: "Try one question solo before switching to Solve together so you build recall first.",
},
word_problem_weak: {
title: "Word problems are the main thing to unlock",
recommendation: "Underline key information and turn the question into a quick number sentence first.",
},
stable_strong: {
title: "You are in a strong learning rhythm",
recommendation: "Keep stretching with harder mixed practice so the easier wins stay automatic.",
},
stable_mid: {
title: "You are close to a really strong streak",
recommendation: "A few focused sessions on weaker topics should move your average up quickly.",
},
stable_weak: {
title: "A steadier routine will help most right now",
recommendation: "Aim for short, regular practice blocks before trying bigger mixed assignments.",
},
};
const activePersona = personaSummary[student._persona] ?? {
title: "Keep building momentum",
recommendation: "Stay focused on one weak area at a time and finish current work before starting new tasks.",
};
const assignmentAccent = (status: StudentAssignment["status"]): AssignmentCard["accent"] => {
switch (status) {
case "IN_PROGRESS":
return "blue";
case "NOT_STARTED":
return "yellow";
default:
return "teal";
}
};
const assignmentCta = (status: StudentAssignment["status"]) => {
switch (status) {
case "IN_PROGRESS":
return "Resume now";
case "NOT_STARTED":
return "Start now";
default:
return "View recap";
}
};
const assignmentStatusCopy = (assignment: StudentAssignment) => {
if (assignment.status === "SUBMITTED") {
return `${assignment.assignment.topic} · Submitted · Score ${assignment.total_marks}/${assignment.assignment.maximum_marks}`;
}
if (assignment.status === "IN_PROGRESS") {
return `${assignment.assignment.topic} · ${assignment.answerCount} questions answered · Due ${formatDueDate(assignment.assignment.due_date)}`;
}
return `${assignment.assignment.topic} · Not started · Due ${formatDueDate(assignment.assignment.due_date)}`;
};
const assignmentCards: AssignmentCard[] = [...studentAssignments]
.sort((left, right) => {
const weight = { IN_PROGRESS: 0, NOT_STARTED: 1, SUBMITTED: 2 } as const;
return weight[left.status] - weight[right.status] || left.assignment.due_date - right.assignment.due_date;
})
.slice(0, 4)
.map((assignment) => ({
title: assignment.assignment.name,
lessons: assignmentStatusCopy(assignment),
accent: assignmentAccent(assignment.status),
cta: assignmentCta(assignment.status),
href: `/assignment/${assignment.assignment.id}`,
}));
const submittedTrend = submittedAssignments.slice(-6).map((assignment) => ({
label: assignment.assignment.topic.length > 8 ? assignment.assignment.topic.slice(0, 8) : assignment.assignment.topic,
value: assignment.assignment.maximum_marks > 0 ? Math.round((assignment.total_marks / assignment.assignment.maximum_marks) * 100) : 0,
}));
const usageSummary = {
note: topSolveMode
? `You use ${topSolveMode.label} for ${topSolveMode.value}% of your answers. When ${weakestTopic?.label?.toLowerCase() ?? "a topic"} gets tricky, try Step by step for a calmer second try.`
: "Try a mix of independent and guided solving to learn what helps you most.",
};
export const classroomSummary = {
name: dataset.classroom.name,
targetLevel: dataset.classroom.target_level,
inviteCode: dataset.classroom.invite_code,
tutorName: dataset.tutor.fullname,
tutorRole: "Lead tutor",
tutorInitials: initialsFor(dataset.tutor.fullname),
};
export const sidebarLinks: SidebarLink[] = [
{ label: "Home", detail: "Today", icon: "⌂", href: "/dashboard", active: true },
{ label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: "/dashboard/assignments" },
{ label: "Progress", detail: `${Math.round(overallAccuracy)}% accuracy`, icon: "↗", href: "/dashboard/progress" },
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: "/dashboard/practice" },
{ label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: "/dashboard/messages" },
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: "/dashboard/settings" },
];
export const sidebarSupport = {
avatars: [currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), weakestTopic?.label.slice(0, 2) ?? "WK", nextNotStartedAssignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "GO"],
title: "Todays study plan",
description: `Pick up ${currentAssignment.assignment.name.split("—")[0].trim()}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${nextNotStartedAssignment ? nextNotStartedAssignment.assignment.name.split("—")[0].trim() : "your next task"} while it still feels easy.`,
buttonLabel: "Open my plan",
buttonHref: `/assignment/${currentAssignment.assignment.id}/work`,
};
export const topbarSummary = {
searchPlaceholder: "Search assignments, hints, or question topics",
profileName: student.fullname,
profileRole: `${dataset.classroom.name} · Student`,
profileBadge: initialsFor(student.fullname),
notificationCount: 3,
messageCount: 1,
};
export const topbarNotifications: TopbarNotificationItem[] = [
{
title: `${currentAssignment.assignment.name.split("—")[0].trim()} is still live`,
description: currentAssignment.status === "SUBMITTED"
? `You have already finished this task. Open the review and clean up any missed marks.`
: `${currentAssignment.answerCount} questions are already touched. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)}.`,
timestamp: `${daysUntil(currentAssignment.assignment.due_date)}d left`,
href: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
tone: currentAssignment.status === "SUBMITTED" ? "teal" : "blue",
},
{
title: `${weakestTopic?.label ?? "Focus practice"} needs a quick pass`,
description: weakestTopic
? `${weakestTopic.value}% accuracy across ${weakestTopic.total} recent questions. A short focused block here should move the needle fastest.`
: "Open a short practice block and work through your next weak spot.",
timestamp: "Practice now",
href: "/dashboard/practice",
tone: "yellow",
},
{
title: `${dataset.tutor.fullname.split(" ")[0]} left feedback`,
description: `There is a fresh tutor message waiting with a calm next-step suggestion for ${weakestTopic?.label?.toLowerCase() ?? "your current focus"}.`,
timestamp: "Today",
href: "/dashboard/messages",
tone: "teal",
},
];
export const heroSummary = {
eyebrow: `Welcome back, ${firstName}`,
title: activePersona.title,
description: `You have finished ${submittedAssignments.length} assignments so far. Right now, the best next step is to keep ${currentAssignment.assignment.name.split("—")[0].trim()} moving and give ${weakestTopic?.label ?? "your focus topic"} a short confidence boost.`,
visualBadges: [formatPercent(overallAccuracy), `${currentAssignment.assignment.name.split("—")[0].trim()} live`, `${weakestTopic?.label ?? "Focus"} focus`],
};
export const spotlightStats: SpotlightStat[] = [
{ label: "Assignments done", value: `${submittedAssignments.length}/${studentAssignments.length}`, tone: "purple" },
{ label: "Current focus", value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" },
{ label: "Best topic", value: strongestTopic?.label ?? "Building", tone: "blue" },
];
export const heroSideCard = {
title: currentAssignment.status === "SUBMITTED" ? `Nice work on ${currentAssignment.assignment.name.split("—")[0].trim()}` : `Pick up where you left off`,
description:
currentAssignment.status === "SUBMITTED"
? `You scored ${currentAssignment.total_marks}/${currentAssignment.assignment.maximum_marks}. A quick recap now will make the next assignment feel easier.`
: `${currentAssignment.answerCount} questions are already done. Finish it by ${formatDueDate(currentAssignment.assignment.due_date)} while the topic is still fresh.`,
buttonLabel: currentAssignment.status === "SUBMITTED" ? "Review this work" : "Resume assignment",
buttonHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
};
export const quickStats: QuickStat[] = [
{ label: "Next due", value: `${daysUntil(currentAssignment.assignment.due_date)}d` },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
];
export { assignmentCards };
export const assignmentFocusStats: AssignmentFocusStat[] = [
{ label: "Live now", value: `${pendingAssignments.length}` },
{ label: "Completed", value: `${submittedAssignments.length}` },
{ label: "Average score", value: formatPercent(overallAccuracy) },
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
];
export const progressFocusStats: AssignmentFocusStat[] = [
{ label: "Accuracy", value: formatPercent(overallAccuracy) },
{ label: "Marked", value: `${submittedAssignments.length}` },
{ label: "Strongest", value: strongestTopic?.label ?? "Building" },
{ label: "Study time", value: formatHours(totalDurationSeconds) },
];
export const practiceFocusStats: PracticeFocusStat[] = [
{ label: "Focus topic", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Topic score", value: weakestTopic ? formatPercent(weakestTopic.value) : "--" },
{ label: "Best support", value: topSolveMode?.label ?? "Try mixed modes" },
{ label: "Ready now", value: currentAssignment.assignment.name.split("—")[0].trim() },
];
export const practiceFocusCards: PracticeFocusCard[] = [
{
title: `Rebuild ${weakestTopic?.label ?? "your focus topic"}`,
description: `Start with the topic that is costing you the most marks right now, then return to your current assignment while the method is still fresh.`,
meta: weakestTopic ? `${weakestTopic.total} recent questions · ${weakestTopic.value}% accuracy` : "Short, focused practice is the fastest win.",
tone: "yellow",
primaryLabel: "Open practice",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "View progress",
secondaryHref: "/dashboard/progress",
},
{
title: "Try one independent pass",
description: topSolveMode
? `${topSolveMode.label} is your default. Try answering one question solo before switching modes so you build stronger recall.`
: "Use one question to test yourself first, then bring in guided help if you need it.",
meta: `Current assignment: ${currentAssignment.assignment.name}`,
tone: "blue",
primaryLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume work" : "Start assignment",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "Review task",
secondaryHref: `/assignment/${currentAssignment.assignment.id}`,
},
{
title: `Finish with ${strongestTopic?.label ?? "a strong topic"}`,
description: `Once the hard bit is done, finish your session on something you usually get right so your next study block starts with confidence.`,
meta: strongestTopic ? `${strongestTopic.value}% accuracy in ${strongestTopic.label}` : "Pick a familiar topic for a strong finish.",
tone: "teal",
primaryLabel: "See assignments",
primaryHref: "/dashboard/assignments",
secondaryLabel: "Open dashboard",
secondaryHref: "/dashboard",
},
];
export const messageFocusStats: MessageFocusStat[] = [
{ label: "Tutor", value: dataset.tutor.fullname.split(" ")[0] },
{ label: "Unread", value: "3" },
{ label: "Latest topic", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
];
export const messageFocusThreads: MessageFocusThread[] = [
{
sender: dataset.tutor.fullname,
role: "Tutor",
initials: initialsFor(dataset.tutor.fullname),
preview: `Lets spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. You only need a couple of calm wins here to lift your confidence.`,
timestamp: "Today",
unread: true,
actionLabel: "Open practice",
actionHref: "/dashboard/practice",
},
{
sender: "Assignment check-in",
role: currentAssignment.assignment.name.split("—")[0].trim(),
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
preview: currentAssignment.status === "SUBMITTED"
? `Nice work finishing this one. Review the marked questions before you start the next task.`
: `${currentAssignment.answerCount} questions are already done. Finish this assignment before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`,
timestamp: currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`,
actionLabel: currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume work",
actionHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
},
{
sender: "Study coach",
role: "Quick reminder",
initials: "SC",
preview: `Your strongest topic is ${strongestTopic?.label ?? "building"}. End today with one easier question there after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`,
timestamp: "2 days ago",
actionLabel: "View progress",
actionHref: "/dashboard/progress",
},
];
export const topbarMessages = messageFocusThreads.slice(0, 3);
export const settingsFocusStats: SettingsFocusStat[] = [
{ label: "Student", value: firstName },
{ label: "Class", value: `Year ${dataset.classroom.target_level}` },
{ label: "Focus", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Goal", value: `${Math.max(Math.round(overallAccuracy) + 10, 70)}%` },
];
export const settingsFocusPanels: SettingsFocusPanel[] = [
{
title: "Profile",
description: "The learner details and classroom context this dashboard is currently designed around.",
rows: [
{ label: "Name", value: student.fullname },
{ label: "Role", value: `${dataset.classroom.name} · Student` },
{ label: "Tutor", value: dataset.tutor.fullname },
{ label: "Invite code", value: dataset.classroom.invite_code },
],
primaryLabel: "Open messages",
primaryHref: "/dashboard/messages",
secondaryLabel: "View progress",
secondaryHref: "/dashboard/progress",
},
{
title: "Learning preferences",
description: "A few smart defaults for how this learner is currently working best.",
rows: [
{ label: "Best support mode", value: topSolveMode?.label ?? "Mixed support" },
{ label: "Strongest topic", value: strongestTopic?.label ?? "Building confidence" },
{ label: "Needs attention", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
],
primaryLabel: "Open practice",
primaryHref: "/dashboard/practice",
secondaryLabel: "See assignments",
secondaryHref: "/dashboard/assignments",
},
{
title: "Goals and reminders",
description: "Settings-like controls can start by summarising the targets and nudges this learner should keep in view.",
rows: [
{ label: "Current goal", value: `Lift ${weakestTopic?.label?.toLowerCase() ?? "focus work"} by 10%` },
{ label: "Live assignments", value: `${pendingAssignments.length}` },
{ label: "Study time", value: formatHours(totalDurationSeconds) },
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
],
primaryLabel: "Open todays plan",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "Go to dashboard",
secondaryHref: "/dashboard",
},
];
const mapAssignmentFocusItem = (assignment: StudentAssignment): AssignmentFocusItem => {
const isSubmitted = assignment.status === "SUBMITTED";
const isInProgress = assignment.status === "IN_PROGRESS";
return {
title: assignment.assignment.name,
meta: assignment.assignment.topic,
subMeta: isSubmitted
? `Marked ${assignment.total_marks}/${assignment.assignment.maximum_marks} · Reviewed ${assignment.answerCount} questions`
: `Due ${formatDueDate(assignment.assignment.due_date)} · ${assignment.answerCount} questions touched so far`,
statusLabel: isSubmitted ? "Completed" : isInProgress ? "In progress" : "Ready to start",
progressText: isSubmitted ? `${assignment.accuracy}% accuracy` : `${daysUntil(assignment.assignment.due_date)}d left`,
tone: isSubmitted ? "teal" : isInProgress ? "blue" : "yellow",
primaryLabel: isSubmitted ? "Open review" : isInProgress ? "Resume work" : "Start assignment",
primaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}` : `/assignment/${assignment.assignment.id}/work`,
secondaryLabel: isSubmitted ? "Open workspace" : "See review",
secondaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}/work` : `/assignment/${assignment.assignment.id}`,
};
};
export const assignmentFocusGroups: AssignmentFocusGroup[] = [
{
title: "Continue now",
description: "Assignments that already have momentum and are best finished next.",
items: studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").map(mapAssignmentFocusItem),
},
{
title: "Coming up",
description: "Work that is live but not started yet, so you can choose what to begin early.",
items: studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED").map(mapAssignmentFocusItem),
},
{
title: "Completed",
description: "Marked work you can revisit for review, correction, and confidence boosts.",
items: studentAssignments.filter((assignment) => assignment.status === "SUBMITTED").map(mapAssignmentFocusItem),
},
].filter((group) => group.items.length > 0);
export const activitySummary = {
title: "Recent results",
note: `${submittedAssignments.length} assignments have been marked so far. Use this as a quick check on what is improving and what still needs another pass.`,
badge: `${submittedAssignments.length} marked`,
};
export const progressPoints = submittedTrend.map((point) => point.value);
export const progressLabels = submittedTrend.map((point) => point.label);
export const highlightCards: HighlightCard[] = [
{
value: `${studentAnswers.length}`,
label: "Questions answered",
note: `You have already submitted ${submittedAssignments.length} assignments and still have ${pendingAssignments.length} live tasks to work through.`,
tone: "yellow",
},
{
value: formatHours(totalDurationSeconds),
label: "Time invested",
note: `Last active ${formatLastSeen(latestActivityTime)}. ${activePersona.recommendation}`,
tone: "pink",
},
];
export const studentSupportList: StudentSupportCard[] = [
{
name: `Review ${weakestTopic?.label ?? "your focus topic"}`,
meta: `${weakestTopic?.value ?? 0}% accuracy across ${weakestTopic?.total ?? 0} questions. A short refresher here should give you the fastest win.`,
initials: weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK",
actionLabel: "Start practice",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
{
name: `Finish ${currentAssignment.assignment.name.split("—")[0].trim()}`,
meta:
currentAssignment.status === "IN_PROGRESS"
? `${currentAssignment.answerCount} questions are already done. One more focused block should move this over the line.`
: `This is your next live assignment. Starting early will make it feel much lighter later in the week.`,
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
actionLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume" : "Start",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
{
name: "Try one question solo",
meta: topSolveMode
? `${topSolveMode.label} is your default mode. Try one independent attempt before asking for help on the next tricky question.`
: "Try guided support on tougher questions, then return to independent practice.",
initials: "IP",
actionLabel: "Use this tip",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
];
export { topicMasteryBars };
export const overallPassRate = Math.round(overallAccuracy);
export { solveModeUsage, usageSummary };

View File

@@ -0,0 +1,55 @@
import { createContext, createSignal, onMount, useContext, type ParentComponent } from "solid-js";
type ThemeMode = "light" | "dark";
type ThemeContextType = {
mode: () => ThemeMode;
toggleMode: () => void;
isReady: () => boolean;
};
const THEME_STORAGE_KEY = "boostai-color-mode";
const ThemeContext = createContext<ThemeContextType>();
const getInitialMode = (): ThemeMode => {
if (typeof window === "undefined") return "light";
const storedMode = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedMode === "light" || storedMode === "dark") return storedMode;
const attributeMode = document.documentElement.getAttribute("data-color-scheme");
if (attributeMode === "light" || attributeMode === "dark") return attributeMode;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
export const ThemeProvider: ParentComponent = (props) => {
const [mode, setMode] = createSignal<ThemeMode>("light");
const [isReady, setIsReady] = createSignal(false);
const applyMode = (nextMode: ThemeMode) => {
setMode(nextMode);
document.documentElement.setAttribute("data-color-scheme", nextMode);
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
};
const toggleMode = () => {
applyMode(mode() === "light" ? "dark" : "light");
};
onMount(() => {
applyMode(getInitialMode());
setIsReady(true);
});
return <ThemeContext.Provider value={{ mode, toggleMode, isReady }}>{props.children}</ThemeContext.Provider>;
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};

View File

@@ -1,4 +1,4 @@
// Path: src/entry-server.tsx
// Path: Frontend/src/entry-server.tsx
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server";
@@ -12,6 +12,9 @@ export default createHandler(() => (
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
<link rel="preload" href="/fonts/Poppins/Poppins-Light.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/fonts/Poppins/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
</head>
<body>
<div id="app">{children}</div>

View File

@@ -0,0 +1,9 @@
// Path: Frontend/src/routes/assignment/[id].tsx
import type { ParentComponent } from "solid-js";
const AssignmentRouteLayout: ParentComponent = (props) => {
return <>{props.children}</>;
};
export default AssignmentRouteLayout;

View File

@@ -0,0 +1,398 @@
.page {
min-height: 100dvh;
padding: 1.25rem;
background:
radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%),
radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%),
linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end));
}
.shell {
width: min(100%, 96rem);
margin: 0 auto;
display: grid;
gap: 1.25rem;
}
.submitBanner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.1rem 1.25rem;
border-radius: 1.35rem;
background: var(--surface-success);
border: 1px solid color-mix(in srgb, var(--success) 28%, transparent);
box-shadow: var(--shadow-soft);
p:last-child {
color: var(--text-muted);
}
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
.bannerEyebrow,
.eyebrow,
.emptyEyebrow,
.helperEyebrow,
.questionEyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.76rem;
color: var(--text-muted);
}
.bannerEyebrow {
color: var(--success);
font-weight: 600;
}
.bannerLink {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1rem;
border-radius: 9999px;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
color: var(--text);
text-decoration: none;
font-weight: 600;
}
.headerCard {
display: grid;
gap: 1rem;
padding: 1.4rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
border: 1px solid var(--border-overlay);
box-shadow: var(--shadow-elevated);
color: var(--text-on-accent);
}
.headerTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.backLink,
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.8rem;
border-radius: 9999px;
background: var(--surface-overlay-soft);
border: 1px solid var(--border-overlay);
color: var(--text-on-accent);
font-size: 0.85rem;
text-decoration: none;
}
.headerCopy {
display: grid;
gap: 0.4rem;
h1 {
font-size: clamp(2rem, 1.55rem + 1.3vw, 3rem);
line-height: 1;
letter-spacing: -0.04em;
}
p {
max-width: 60ch;
}
}
.eyebrow {
color: var(--text-on-accent-muted);
}
.headerMeta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.saveState {
font-size: 0.92rem;
font-weight: 500;
color: var(--text-on-accent-muted);
}
.submitButton,
.primaryButton,
.secondaryButton,
.backButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.9rem 1rem;
border-radius: 1rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
.submitButton,
.backButton,
.primaryButton {
border: none;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
}
}
.contentGrid {
display: grid;
gap: 1.25rem;
@media (min-width: 1080px) {
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
align-items: start;
}
}
.navigator,
.workspace,
.emptyState {
display: grid;
gap: 1rem;
padding: 1.2rem;
border-radius: 1.35rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.navigator {
@media (min-width: 1080px) {
position: sticky;
top: 1.25rem;
}
}
.navigatorHeader,
.workspaceHeader {
display: grid;
gap: 0.2rem;
h2 {
font-size: 1.2rem;
line-height: 1.12;
letter-spacing: -0.03em;
}
p {
color: var(--text-muted);
font-size: 0.9rem;
}
}
.progressMeta {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.8rem 0.9rem;
border-radius: 1rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
font-size: 0.85rem;
color: var(--text-muted);
}
.questionButtons {
display: grid;
gap: 0.65rem;
}
.questionButton {
display: grid;
gap: 0.15rem;
padding: 0.85rem 0.95rem;
text-align: left;
border-radius: 1rem;
border: 1px solid var(--border-divider);
background: var(--surface-panel-strong);
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
transform: translateY(-1px);
}
span {
font-weight: 600;
}
small {
color: var(--text-muted);
font-size: 0.82rem;
}
}
.activeQuestion {
border-color: var(--info);
background: var(--surface-info);
}
.completedQuestion {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--success) 30%, transparent);
}
.questionMeta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
span {
padding: 0.35rem 0.6rem;
border-radius: 9999px;
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
font-size: 0.82rem;
color: var(--text-muted);
}
}
.questionStatus {
margin-top: 0.4rem;
font-size: 0.92rem;
font-weight: 500;
color: var(--info);
}
.fieldGroup {
display: grid;
gap: 0.45rem;
span {
font-size: 0.88rem;
font-weight: 500;
}
input,
textarea,
select {
width: 100%;
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid var(--border-field);
background: var(--surface-field);
outline: none;
color: var(--text);
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--focus-ring-primary);
background: var(--surface-field-focus);
}
}
textarea {
resize: vertical;
min-height: 10rem;
}
}
.fieldHint {
font-size: 0.82rem;
color: var(--text-muted);
}
.helperCard {
display: grid;
gap: 0.45rem;
padding: 1rem;
border-radius: 1rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
p:last-of-type {
color: var(--text-muted);
}
}
.answerReveal {
display: grid;
gap: 0.15rem;
margin-top: 0.3rem;
span {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
strong {
font-size: 1rem;
}
}
.actionRow {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.primaryActions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.secondaryButton {
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
color: var(--text);
&:hover {
transform: translateY(-1px);
background: var(--surface-soft);
}
}
.questionFooter {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-muted);
}
.emptyState {
h1 {
font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p {
color: var(--text-muted);
}
}

View File

@@ -0,0 +1,48 @@
import { A, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import { createMemo } from "solid-js";
import AssignmentHeader from "../../../components/assignment/assignment-header";
import AssignmentOverview from "../../../components/assignment/assignment-overview";
import AssignmentQuestionList from "../../../components/assignment/assignment-question-list";
import AssignmentTabs from "../../../components/assignment/assignment-tabs";
import { getAssignmentPageData } from "../../../components/assignment/assignment.data";
import styles from "../assignment-page.module.scss";
const AssignmentPage: Component = () => {
const params = useParams();
const assignmentData = createMemo(() => getAssignmentPageData(Number(params.id)));
return (
<main class={styles.page}>
<div class={styles.shell}>
{assignmentData() ? (
<>
<AssignmentHeader data={assignmentData()!} />
<AssignmentTabs />
<div class={styles.contentGrid}>
<div class={styles.mainColumn}>
<AssignmentQuestionList data={assignmentData()!} />
</div>
<aside class={styles.sideColumn}>
<AssignmentOverview data={assignmentData()!} />
</aside>
</div>
</>
) : (
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Assignment not found</p>
<h1>We could not find that sample assignment.</h1>
<p>Try one of the mock IDs like 3006 or 3007, or jump back to the dashboard.</p>
<A href="/dashboard" class={styles.backButton}>
Back to dashboard
</A>
</section>
)}
</div>
</main>
);
};
export default AssignmentPage;

View File

@@ -0,0 +1,382 @@
import { A, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { getAssignmentPageData } from "../../../components/assignment/assignment.data";
import AssignmentTabs from "../../../components/assignment/assignment-tabs";
import styles from "./assignment-work.module.scss";
type DraftState = Record<
number,
{
answer: string;
steps: string;
solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
}
>;
type PersistedWorkspaceState = {
drafts: DraftState;
activeQuestionId: number | null;
isSubmitted: boolean;
submittedAt: string | null;
lastSavedAt: string | null;
};
const solveModeOptions = [
{ value: "just_answer", label: "Just answer" },
{ value: "step_by_step", label: "Step by step" },
{ value: "solve_together", label: "Solve together" },
{ value: "handwritten", label: "Handwritten" },
] as const;
const isClient = typeof window !== "undefined";
const AssignmentWorkPage: Component = () => {
const params = useParams();
const assignmentData = createMemo(() => getAssignmentPageData(Number(params.id)));
const storageKey = createMemo(() => `boostai-assignment-work-${params.id}`);
const initialDrafts = createMemo<DraftState>(() => {
const data = assignmentData();
if (!data) return {};
return Object.fromEntries(
data.questions.map((question) => [
question.id,
{
answer: question.initialAnswer ?? "",
steps: "",
solveMode: question.initialSolveMode ?? "just_answer",
},
]),
) as DraftState;
});
const [drafts, setDrafts] = createSignal<DraftState>({});
const [activeQuestionId, setActiveQuestionId] = createSignal<number | null>(null);
const [saveState, setSaveState] = createSignal<"idle" | "saving" | "saved">("idle");
const [lastSavedAt, setLastSavedAt] = createSignal<string | null>(null);
const [isSubmitted, setIsSubmitted] = createSignal(false);
const [submittedAt, setSubmittedAt] = createSignal<string | null>(null);
const readPersistedState = (): PersistedWorkspaceState | null => {
if (!isClient) return null;
const raw = window.localStorage.getItem(storageKey());
if (!raw) return null;
try {
return JSON.parse(raw) as PersistedWorkspaceState;
} catch {
return null;
}
};
const writePersistedState = (overrideSavedAt?: string) => {
if (!isClient || !assignmentData()) return;
const savedAt = overrideSavedAt ?? new Date().toISOString();
window.localStorage.setItem(
storageKey(),
JSON.stringify({
drafts: drafts(),
activeQuestionId: activeQuestionId(),
isSubmitted: isSubmitted(),
submittedAt: submittedAt(),
lastSavedAt: savedAt,
}),
);
setLastSavedAt(savedAt);
setSaveState("saved");
};
createEffect(() => {
const data = assignmentData();
if (!data) return;
const persisted = readPersistedState();
setDrafts({
...initialDrafts(),
...(persisted?.drafts ?? {}),
});
setActiveQuestionId(persisted?.activeQuestionId ?? data.questions[0]?.id ?? null);
setIsSubmitted(persisted?.isSubmitted ?? false);
setSubmittedAt(persisted?.submittedAt ?? null);
setLastSavedAt(persisted?.lastSavedAt ?? null);
setSaveState("idle");
});
const questions = createMemo(() => assignmentData()?.questions ?? []);
const currentQuestion = createMemo(() => questions().find((question) => question.id === activeQuestionId()) ?? questions()[0]);
const activeDraft = createMemo(() => (currentQuestion() ? drafts()[currentQuestion()!.id] : undefined));
const answeredCount = createMemo(() => Object.values(drafts()).filter((draft) => draft.answer.trim().length > 0).length);
const remainingCount = createMemo(() => Math.max(questions().length - answeredCount(), 0));
const isLastQuestion = createMemo(() => {
const current = currentQuestion();
if (!current) return false;
return questions()[questions().length - 1]?.id === current.id;
});
const saveLabel = createMemo(() => {
if (saveState() === "saving") return "Saving draft...";
if (saveState() === "saved" && lastSavedAt()) {
return `Saved ${new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(lastSavedAt()!))}`;
}
return "Draft stored on this device";
});
let saveTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
if (!isClient || !assignmentData()) return;
drafts();
activeQuestionId();
isSubmitted();
submittedAt();
setSaveState("saving");
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
writePersistedState();
}, 350);
});
onCleanup(() => {
if (saveTimer) clearTimeout(saveTimer);
});
const updateDraft = (field: "answer" | "steps" | "solveMode", value: string) => {
const question = currentQuestion();
if (!question) return;
setDrafts((current) => ({
...current,
[question.id]: {
...current[question.id],
[field]: value,
},
}));
setIsSubmitted(false);
};
const saveNow = () => {
if (saveTimer) clearTimeout(saveTimer);
writePersistedState();
};
const moveQuestion = (direction: 1 | -1) => {
const current = currentQuestion();
if (!current) return;
const index = questions().findIndex((question) => question.id === current.id);
const target = questions()[index + direction];
if (target) setActiveQuestionId(target.id);
};
const handleSaveAndContinue = () => {
saveNow();
if (!isLastQuestion()) moveQuestion(1);
};
const handleSubmit = () => {
const savedAt = new Date().toISOString();
setIsSubmitted(true);
setSubmittedAt(savedAt);
writePersistedState(savedAt);
};
return (
<main class={styles.page}>
<div class={styles.shell}>
<Show
when={assignmentData()}
fallback={
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Assignment not found</p>
<h1>We could not find that assignment workspace.</h1>
<A href="/dashboard" class={styles.backButton}>
Back to dashboard
</A>
</section>
}
>
{(data) => (
<>
<Show when={isSubmitted()}>
<section class={styles.submitBanner}>
<div>
<p class={styles.bannerEyebrow}>Practice submitted</p>
<h2>
You answered {answeredCount()} of {data().questions.length} questions.
</h2>
<p>Your latest draft is saved locally. You can review the assignment or keep refining any answer.</p>
</div>
<A href={`/assignment/${data().id}`} class={styles.bannerLink}>
Open review page
</A>
</section>
</Show>
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href={`/assignment/${data().id}`} class={styles.backLink}>
Back to review
</A>
<span class={styles.statusPill}>{data().statusLabel}</span>
</div>
<div class={styles.headerCopy}>
<p class={styles.eyebrow}>{data().classroomName}</p>
<h1>Work through {data().title}</h1>
<p>Answer each question, show your working, and move at a steady pace. Your draft autosaves on this device while you work.</p>
</div>
<div class={styles.headerMeta}>
<p class={styles.saveState}>{saveLabel()}</p>
<button type="button" class={styles.submitButton} onClick={handleSubmit}>
{remainingCount() > 0 ? `Submit ${answeredCount()}/${data().questions.length}` : "Submit assignment"}
</button>
</div>
</section>
<AssignmentTabs />
<div class={styles.contentGrid}>
<aside class={styles.navigator}>
<div class={styles.navigatorHeader}>
<h2>Question list</h2>
<p>
{answeredCount()} of {data().questions.length} answered
</p>
</div>
<div class={styles.progressMeta}>
<span>{remainingCount()} left</span>
<span>{saveLabel()}</span>
</div>
<div class={styles.questionButtons}>
<For each={data().questions}>
{(question) => {
const hasDraft = () => (drafts()[question.id]?.answer ?? "").trim().length > 0;
return (
<button
type="button"
classList={{
[styles.questionButton]: true,
[styles.activeQuestion]: activeQuestionId() === question.id,
[styles.completedQuestion]: hasDraft(),
}}
onClick={() => setActiveQuestionId(question.id)}
>
<span>Q{question.order}</span>
<small>{hasDraft() ? "Draft ready" : question.statusLabel}</small>
</button>
);
}}
</For>
</div>
</aside>
<section class={styles.workspace}>
<Show when={currentQuestion() && activeDraft()}>
{() => (
<>
<div class={styles.workspaceHeader}>
<div>
<p class={styles.questionEyebrow}>Question {currentQuestion()!.order}</p>
<h2>{currentQuestion()!.prompt}</h2>
<p class={styles.questionStatus}>{currentQuestion()!.statusLabel}</p>
</div>
<div class={styles.questionMeta}>
<span>{currentQuestion()!.topic}</span>
<Show when={currentQuestion()!.subTopic}>
<span>{currentQuestion()!.subTopic}</span>
</Show>
<span>{currentQuestion()!.difficulty}</span>
</div>
</div>
<label class={styles.fieldGroup}>
<span>Solve mode</span>
<select value={activeDraft()!.solveMode} onInput={(event) => updateDraft("solveMode", event.currentTarget.value)}>
<For each={solveModeOptions}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
<small class={styles.fieldHint}>Pick the mode that best matches how you want to think through this question.</small>
</label>
<label class={styles.fieldGroup}>
<span>Your answer</span>
<input
type="text"
value={activeDraft()!.answer}
onInput={(event) => updateDraft("answer", event.currentTarget.value)}
placeholder="Type your final answer"
/>
<small class={styles.fieldHint}>Keep this short your final number, fraction, or expression.</small>
</label>
<label class={styles.fieldGroup}>
<span>Working / steps</span>
<textarea
rows={8}
value={activeDraft()!.steps}
onInput={(event) => updateDraft("steps", event.currentTarget.value)}
placeholder="Write your method here. For example: find a common denominator, simplify, then check your final answer."
/>
<small class={styles.fieldHint}>Show enough working that you could explain it back to your teacher later.</small>
</label>
<div class={styles.helperCard}>
<p class={styles.helperEyebrow}>Helpful prompt</p>
<p>Try to explain why each step makes sense, not just what number you wrote next.</p>
<Show when={currentQuestion()!.showAnswerKey}>
<div class={styles.answerReveal}>
<span>Answer key</span>
<strong>{currentQuestion()!.correctAnswer}</strong>
</div>
</Show>
</div>
<div class={styles.actionRow}>
<button type="button" class={styles.secondaryButton} onClick={() => moveQuestion(-1)}>
Previous
</button>
<div class={styles.primaryActions}>
<button type="button" class={styles.secondaryButton} onClick={saveNow}>
Save draft
</button>
<button type="button" class={styles.primaryButton} onClick={handleSaveAndContinue}>
{isLastQuestion() ? "Save this question" : "Save and continue"}
</button>
</div>
</div>
<div class={styles.questionFooter}>
<p>{saveLabel()}</p>
<Show when={submittedAt()}>
<p>
Last submitted {new Intl.DateTimeFormat("en-GB", { hour: "numeric", minute: "2-digit" }).format(new Date(submittedAt()!))}
</p>
</Show>
</div>
</>
)}
</Show>
</section>
</div>
</>
)}
</Show>
</div>
</main>
);
};
export default AssignmentWorkPage;

View File

@@ -0,0 +1,128 @@
.page {
min-height: 100dvh;
padding: 1.25rem;
background:
radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%),
radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%),
linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end));
}
.shell {
width: min(100%, 92rem);
margin: 0 auto;
display: grid;
gap: 1.25rem;
}
.contentGrid {
display: grid;
gap: 1.25rem;
@media (min-width: 1080px) {
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr);
align-items: start;
}
}
.tabs {
display: flex;
justify-content: flex-start;
}
.tabList {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem;
border-radius: 9999px;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
overflow-x: auto;
max-width: 100%;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabLink {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.72rem 1rem;
border-radius: 9999px;
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover {
color: var(--text);
background: var(--surface-soft);
}
}
.tabLinkActive {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
}
.mainColumn,
.sideColumn {
min-width: 0;
}
.sideColumn {
@media (min-width: 1080px) {
position: sticky;
top: 1.25rem;
}
}
.emptyState {
display: grid;
gap: 0.8rem;
padding: 2rem;
border-radius: 1.5rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
h1 {
font-size: clamp(2rem, 1.7rem + 1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p {
color: var(--text-muted);
}
}
.emptyEyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.78rem;
color: var(--text-muted) !important;
}
.backButton {
width: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1rem;
border-radius: 9999px;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
text-decoration: none;
font-weight: 600;
box-shadow: var(--action-primary-shadow);
}

View File

@@ -0,0 +1,66 @@
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const ForgotPassword: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/auth/login");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Account help</p>
<h1 class={styles.title}>Reset your password</h1>
<p class={styles.description}>We&apos;ll send you a reset link so you can get back into your dashboard quickly.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Check your inbox</h2>
<p>Use the same email address you signed up with and we&apos;ll send a recovery link there.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Create a fresh password</h2>
<p>Pick a strong new password, then sign back in and continue your assignments.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Forgot password?</h2>
<p>Enter your email and we&apos;ll send you a reset link.</p>
</div>
<div class={styles["field-group"]}>
<label for="reset-email">Email address</label>
<input id="reset-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<button type="submit">Send reset link</button>
<p class={styles["support-copy"]}>
Remembered it? <A href="/auth/login">Back to sign in</A>
</p>
</form>
</section>
</main>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,227 @@
/* Path: Frontend/src/routes/auth/login.module.scss */
.login-page {
min-height: 100dvh;
width: 100%;
overflow: clip;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%),
radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%),
linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end));
}
.login-shell {
width: min(100%, 70rem);
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
align-items: center;
@include respond(tablet) {
grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 28rem);
align-items: center;
gap: 4rem;
}
}
.login-copy {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.back-link,
.description,
.card-header p,
.field-group label,
.field-group input,
.field-header a,
.checkbox-row,
.footer-copy,
.benefit-item p {
@include text-smallest;
}
.back-link,
.field-header a,
.footer-copy a {
color: var(--primary);
}
.back-link {
width: fit-content;
font-weight: 500;
}
.eyebrow {
@include text-smallest;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.title {
font-size: clamp(2.8rem, 2.2rem + 1.9vw, 4.5rem);
line-height: 1.05;
max-width: 10ch;
}
.description,
.footer-copy,
.card-header p,
.benefit-item p {
color: var(--text-muted);
}
.benefit-list {
display: grid;
gap: 1rem;
margin-top: 0.75rem;
}
.benefit-item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.875rem;
align-items: start;
padding: 1rem 1.1rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
border-radius: 1rem;
}
.benefit-number {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
color: var(--primary);
background: var(--surface-accent-soft);
font-size: 0.9rem;
font-weight: 600;
}
.benefit-item h2 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.login-card {
display: flex;
flex-direction: column;
gap: 1.35rem;
padding: 2rem;
background: var(--surface-raised);
border: 1px solid var(--border-strong);
border-radius: 1.75rem;
box-shadow: var(--shadow-elevated);
backdrop-filter: blur(14px);
}
.card-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
text-align: center;
}
.card-header h2 {
font-size: 1.65rem;
font-weight: 500;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-grid {
display: grid;
gap: 0.85rem;
@media (min-width: 540px) {
grid-template-columns: 1fr 1fr;
}
}
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.field-group input {
width: 100%;
padding: 0.95rem 1rem;
color: var(--text);
background: var(--surface-field);
border: 1px solid var(--border-field);
border-radius: 1rem;
outline: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--focus-ring-primary);
background: var(--surface-field-focus);
}
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.checkbox-row input {
width: 1rem;
height: 1rem;
}
.login-card button {
width: 100%;
padding: 0.95rem 1.25rem;
color: var(--action-primary-text);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
border: none;
border-radius: 9999px;
cursor: pointer;
font-weight: 500;
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
filter: brightness(1.03);
}
}
.footer-copy {
text-align: center;
}
.support-copy {
text-align: center;
color: var(--text-muted);
a {
color: var(--primary);
}
}

View File

@@ -0,0 +1,85 @@
// Path: Frontend/src/routes/auth/login.tsx
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const Login: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/dashboard");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Welcome back</p>
<h1 class={styles.title}>Sign in to Rooster AI</h1>
<p class={styles.description}>Access your learning dashboard, questions, and progress in one place.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Track your progress</h2>
<p>Pick up from the last lesson, review performance, and keep your momentum.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Ask better questions</h2>
<p>Get help on Maths and English topics with a cleaner learning workflow.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Sign in</h2>
<p>Use your email and password to continue.</p>
</div>
<div class={styles["field-group"]}>
<label for="email">Email address</label>
<input id="email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<div class={styles["field-group"]}>
<div class={styles["field-header"]}>
<label for="password">Password</label>
<A href="/auth/forgot-password">Forgot password?</A>
</div>
<input id="password" type="password" name="password" autocomplete="current-password" placeholder="Enter your password" />
</div>
<label class={styles["checkbox-row"]} for="remember-me">
<input id="remember-me" type="checkbox" name="rememberMe" />
<span>Keep me signed in</span>
</label>
<button type="submit">
Sign in
</button>
<p class={styles["footer-copy"]}>
Don&apos;t have an account yet? <A href="/auth/signup">Create one</A>
</p>
</form>
</section>
</main>
);
};
export default Login;

View File

@@ -0,0 +1,99 @@
import { A, useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./login.module.scss";
const Signup: Component = () => {
const navigate = useNavigate();
const handleSubmit = (event: Event) => {
event.preventDefault();
navigate("/dashboard");
};
return (
<main class={styles["login-page"]}>
<section class={styles["login-shell"]}>
<div class={styles["login-copy"]}>
<A href="/" class={styles["back-link"]}>
Back to home
</A>
<p class={styles.eyebrow}>Start learning</p>
<h1 class={styles.title}>Create your account</h1>
<p class={styles.description}>Set up your learner profile and jump straight into assignments, progress, and practice.</p>
<div class={styles["benefit-list"]}>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>01</span>
<div>
<h2>Your own dashboard</h2>
<p>Track assignments, confidence areas, and recent results in one place.</p>
</div>
</div>
<div class={styles["benefit-item"]}>
<span class={styles["benefit-number"]}>02</span>
<div>
<h2>Save your working</h2>
<p>Keep your answers, step-by-step thinking, and practice history connected to your account.</p>
</div>
</div>
</div>
</div>
<form class={styles["login-card"]} onSubmit={handleSubmit}>
<div class={styles["card-header"]}>
<h2>Create account</h2>
<p>Tell us a little about you to get started.</p>
</div>
<div class={styles["field-grid"]}>
<div class={styles["field-group"]}>
<label for="first-name">First name</label>
<input id="first-name" type="text" name="firstName" autocomplete="given-name" placeholder="Aisha" />
</div>
<div class={styles["field-group"]}>
<label for="last-name">Last name</label>
<input id="last-name" type="text" name="lastName" autocomplete="family-name" placeholder="Khan" />
</div>
</div>
<div class={styles["field-group"]}>
<label for="signup-email">Email address</label>
<input id="signup-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" />
</div>
<div class={styles["field-group"]}>
<label for="signup-password">Password</label>
<input id="signup-password" type="password" name="password" autocomplete="new-password" placeholder="Create a password" />
</div>
<div class={styles["field-group"]}>
<label for="signup-confirm-password">Confirm password</label>
<input
id="signup-confirm-password"
type="password"
name="confirmPassword"
autocomplete="new-password"
placeholder="Repeat your password"
/>
</div>
<label class={styles["checkbox-row"]} for="agree-terms">
<input id="agree-terms" type="checkbox" name="agreeTerms" />
<span>I agree to the learning platform terms and understand this is a sample signup flow.</span>
</label>
<button type="submit">Create account</button>
<p class={styles["footer-copy"]}>
Already have an account? <A href="/auth/login">Sign in</A>
</p>
</form>
</section>
</main>
);
};
export default Signup;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardAssignmentsFocus from "../../components/dashboard/dashboard-assignments-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardAssignmentsPage: Component = () => {
return (
<DashboardShell>
<DashboardAssignmentsFocus />
</DashboardShell>
);
};
export default DashboardAssignmentsPage;

View File

@@ -0,0 +1,200 @@
.dashboardPage {
min-height: 100dvh;
padding: 0.85rem;
background:
radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%),
radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%),
linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end));
@media (min-width: 640px) {
padding: 1rem;
}
@media (min-width: 960px) {
padding: 1.25rem;
}
}
.dashboardLayout {
width: min(100%, 100rem);
margin: 0 auto;
display: grid;
gap: 1rem;
--dashboard-sidebar-width: 0rem;
will-change: grid-template-columns;
transition:
grid-template-columns 0.46s cubic-bezier(0.22, 1, 0.36, 1),
gap 0.26s var(--transition-ease);
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1024px) {
--dashboard-sidebar-width: 15.5rem;
grid-template-columns: minmax(0, var(--dashboard-sidebar-width)) minmax(0, 1fr);
grid-template-areas: "sidebar main";
align-items: start;
}
}
.dashboardLayoutSidebarCollapsed {
@media (min-width: 1024px) {
--dashboard-sidebar-width: 0rem;
}
}
.sidebarRail {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
@media (min-width: 1024px) {
position: static;
inset: auto;
z-index: auto;
pointer-events: auto;
overflow: hidden;
min-width: 0;
}
}
.sidebarBackdrop {
position: absolute;
inset: 0;
border: none;
padding: 0;
background: hsl(0 0% 0% / 0.48);
opacity: 0;
pointer-events: none;
transition:
opacity 0.34s var(--transition-ease),
backdrop-filter 0.34s var(--transition-ease);
backdrop-filter: blur(0px);
@media (min-width: 1024px) {
display: none;
}
}
.sidebarBackdropVisible {
opacity: 1;
pointer-events: auto;
backdrop-filter: blur(2px);
}
.sidebarPanel {
position: absolute;
top: 0.85rem;
left: 0.85rem;
bottom: 0.85rem;
width: min(18rem, calc(100vw - 3.2rem));
max-width: 100%;
overflow-y: auto;
transform: translateX(calc(-100% - 1rem));
opacity: 0;
transition:
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.3s var(--transition-ease);
pointer-events: auto;
@media (min-width: 640px) {
top: 1rem;
left: 1rem;
bottom: 1rem;
}
@media (min-width: 1024px) {
position: static;
width: auto;
transform: none;
opacity: 1;
transition:
opacity 0.3s var(--transition-ease),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
visibility 0.3s var(--transition-ease),
max-width 0.46s cubic-bezier(0.22, 1, 0.36, 1);
max-width: 15.5rem;
}
}
.sidebarPanelOpen {
transform: translateX(0);
opacity: 1;
}
.sidebarPanelDesktopCollapsed {
@media (min-width: 1024px) {
max-width: 0;
opacity: 0;
visibility: hidden;
transform: translateX(-1.25rem) scale(0.985);
pointer-events: none;
}
}
.sidebarPanelInner {
transition:
opacity 0.32s var(--transition-ease),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.42s var(--transition-ease);
transform-origin: top left;
}
.sidebarPanelDesktopCollapsed .sidebarPanelInner {
@media (min-width: 1024px) {
opacity: 0;
transform: translateX(-0.9rem) scale(0.98);
filter: blur(4px);
}
}
.dashboardMain {
display: grid;
gap: 1rem;
min-width: 0;
will-change: transform, opacity;
transition:
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.28s var(--transition-ease);
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.dashboardLayoutSidebarCollapsed .dashboardMain {
@media (min-width: 1024px) {
transform: translateX(-0.18rem);
}
}
.contentGrid {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.92fr);
grid-template-areas:
"activity courses"
"activity instructors";
align-items: start;
> :nth-child(1) {
grid-area: courses;
}
> :nth-child(2) {
grid-area: activity;
}
> :nth-child(3) {
grid-area: instructors;
}
}
}

View File

@@ -0,0 +1,26 @@
import type { Component } from "solid-js";
import DashboardActivity from "../../components/dashboard/dashboard-activity";
import DashboardCourses from "../../components/dashboard/dashboard-courses";
import DashboardHero from "../../components/dashboard/dashboard-hero";
import DashboardInsights from "../../components/dashboard/dashboard-insights";
import DashboardInstructors from "../../components/dashboard/dashboard-instructors";
import DashboardShell from "../../components/dashboard/dashboard-shell";
import styles from "./dashboard.module.scss";
const DashboardPage: Component = () => {
return (
<DashboardShell>
<DashboardHero />
<div class={styles.contentGrid}>
<DashboardCourses />
<DashboardActivity />
<DashboardInstructors />
</div>
<DashboardInsights />
</DashboardShell>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardMessagesFocus from "../../components/dashboard/dashboard-messages-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardMessagesPage: Component = () => {
return (
<DashboardShell>
<DashboardMessagesFocus />
</DashboardShell>
);
};
export default DashboardMessagesPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardPracticeFocus from "../../components/dashboard/dashboard-practice-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardPracticePage: Component = () => {
return (
<DashboardShell>
<DashboardPracticeFocus />
</DashboardShell>
);
};
export default DashboardPracticePage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardProgressFocus from "../../components/dashboard/dashboard-progress-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardProgressPage: Component = () => {
return (
<DashboardShell>
<DashboardProgressFocus />
</DashboardShell>
);
};
export default DashboardProgressPage;

View File

@@ -0,0 +1,13 @@
import type { Component } from "solid-js";
import DashboardSettingsFocus from "../../components/dashboard/dashboard-settings-focus";
import DashboardShell from "../../components/dashboard/dashboard-shell";
const DashboardSettingsRoute: Component = () => {
return (
<DashboardShell>
<DashboardSettingsFocus />
</DashboardShell>
);
};
export default DashboardSettingsRoute;

View File

@@ -0,0 +1,26 @@
// Path: Frontend/src/routes/index.tsx
import { useNavigate } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "./landing.module.scss";
const HomePage: Component = () => {
const navigate = useNavigate();
const handleNavigation = () => {
navigate("/auth/login");
};
return (
<main class={styles.landingPage}>
<h1>Rooster AI Demo</h1>
<sub>The leading AI online learning platform for Maths and English</sub>
<button type="button" onClick={handleNavigation}>
Get Started
</button>
</main>
);
};
export default HomePage;

View File

@@ -0,0 +1,50 @@
/* Path: Frontend/src/routes/landing.module.scss */
.landingPage {
min-height: 100dvh;
width: 100%;
overflow: hidden;
padding: 1.5rem;
background:
radial-gradient(circle at top left, var(--page-glow-primary), transparent 30%),
radial-gradient(circle at bottom right, var(--page-glow-secondary), transparent 28%),
linear-gradient(135deg, var(--page-gradient-start), var(--page-gradient-end));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
h1 {
color: var(--primary);
}
sub {
font-weight: 300;
}
button {
margin-top: 2rem;
padding: 0.875rem 1.75rem;
font-size: 1rem;
font-weight: 500;
color: var(--action-primary-text);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
border: none;
border-radius: 9999px;
cursor: pointer;
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
filter: brightness(1.03);
}
}
}

View File

@@ -0,0 +1,31 @@
/* Path: Frontend/src/styles/_fonts.scss */
@font-face {
font-family: "CaveatBrush";
src: url("/fonts/CaveatBrush/CaveatBrush-Regular.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins/Poppins-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,52 @@
/* Path: Frontend/src/styles/_reset.scss */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}

View File

@@ -0,0 +1,125 @@
/* Path: Frontend/src/styles/main.scss */
@use "./reset" as *;
@use "./fonts" as *;
html {
background-color: var(--bg);
// transition:
// background-color var(--transition-speed) var(--transition-ease),
// color var(--transition-speed) var(--transition-ease);
color: var(--text);
font-family:
"Poppins",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
}
.app-route-shell {
position: relative;
width: 100%;
isolation: isolate;
overflow-x: clip;
}
.app-route-shell-viewport-locked {
min-height: 100dvh;
overflow: clip;
}
.page-transition-stage {
width: 100%;
will-change: opacity, transform, filter;
}
.page-fade-slide-enter-active,
.page-fade-slide-exit-active {
transition:
opacity 0.2s var(--transition-ease),
transform 0.24s var(--transition-ease),
filter 0.24s var(--transition-ease);
}
.page-fade-slide-exit-active {
position: absolute;
inset: 0;
pointer-events: none;
}
.page-fade-slide-enter,
.page-fade-slide-exit-to {
opacity: 0;
transform: translateY(0.45rem) scale(0.996);
filter: blur(3px);
}
h1 {
@include text-largest;
}
h2 {
@include text-large;
}
h3 {
@include text-medium;
}
h4 {
@include text-small;
}
h5 {
@include text-smaller;
}
p,
span,
button,
input,
textarea,
sub,
a {
@include text-smallest;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
p,
span,
a,
button,
input,
textarea {
transition: color var(--transition-speed) var(--transition-ease);
}

View File

@@ -0,0 +1,290 @@
/* Path: Frontend/src/styles/vars.scss */
:root {
--transition-speed: 0.5s;
--transition-ease: ease-in-out;
--gray-50: hsl(40 25% 85%);
--gray-100: hsl(40 23% 80%);
--gray-200: hsl(40 20% 77%);
--gray-300: hsl(40 20% 70%);
--gray-400: hsl(40 10% 50%);
--gray-500: hsl(40 5% 40%);
--gray-600: hsl(40 5% 30%);
--gray-700: hsl(40 5% 25%);
--gray-800: hsl(40 5% 20%);
--gray-900: hsl(40 5% 15%);
--purple-100: hsl(263.08, 61.9%, 95.88%);
--purple-200: hsl(264.71, 48.57%, 93.14%);
--purple-300: hsl(266.44, 50.43%, 77.06%);
--purple-400: hsl(259.47, 67.11%, 55.88%);
--purple-500: hsl(261.9, 52.94%, 46.67%);
--purple-600: hsl(267, 27%, 35%);
--purple-700: hsl(266.4, 26.88%, 25.24%);
--purple-800: hsl(266.4, 26.88%, 18.24%);
--green-100: hsl(142.63, 100%, 94.71%);
--green-200: hsl(142.63, 100%, 64.71%);
--green-300: hsl(142.63, 100%, 44.71%);
--green-400: hsl(142.63, 100%, 34.71%);
--green-500: hsl(142.63, 100%, 24.71%);
--blue-100: hsl(204 100% 94%);
--blue-200: hsl(204 92% 84%);
--blue-300: hsl(204 88% 64%);
--blue-400: hsl(204 82% 54%);
--blue-500: hsl(204 76% 48%);
--teal-100: hsl(172 46% 90%);
--teal-200: hsl(172 46% 74%);
--teal-300: hsl(172 43% 57%);
--teal-400: hsl(172 52% 46%);
--teal-500: hsl(172 58% 38%);
--orange-100: hsl(31 100% 92%);
--orange-200: hsl(31 100% 79%);
--orange-300: hsl(31 94% 66%);
--orange-400: hsl(31 89% 57%);
--orange-500: hsl(31 78% 47%);
--red-100: hsl(359 46.6% 90.8%);
--red-200: hsl(359 46.6% 70.8%);
--red-300: hsl(359 46.6% 50.8%);
--red-400: hsl(359 46.6% 40.8%);
--red-500: hsl(359 46.6% 30.8%);
--yellow-100: hsl(56.94, 100%, 90%);
--yellow-200: hsl(56.94, 100%, 70%);
--yellow-300: hsl(56.94, 100%, 50%);
--yellow-400: hsl(56.94, 100%, 40%);
--yellow-500: hsl(56.94, 100%, 30%);
}
:root {
// Background and Text Colors
--bg: var(--gray-50);
--text: var(--gray-800);
--text-muted: var(--gray-700);
// Page Background
--page-glow-primary: hsl(263 62% 96% / 0.9);
--page-glow-secondary: hsl(142 100% 94% / 0.65);
--page-gradient-start: hsl(40 25% 91%);
--page-gradient-end: hsl(40 18% 87%);
// Surface Tokens
--surface-soft: hsl(40 25% 93% / 0.65);
--surface-raised: hsl(40 25% 92% / 0.78);
--surface-field: hsl(40 30% 95% / 0.85);
--surface-field-focus: hsl(40 30% 97% / 0.95);
--surface-accent-soft: hsl(263 62% 92% / 0.9);
--surface-accent-emphasis: hsl(263 62% 88% / 0.95);
--surface-panel: hsl(40 24% 95% / 0.9);
--surface-panel-strong: hsl(40 18% 98% / 0.95);
--surface-info: hsl(204 100% 96% / 0.95);
--surface-info-emphasis: hsl(204 90% 88% / 0.95);
--surface-success: hsl(142 100% 96% / 0.95);
--surface-success-emphasis: hsl(172 44% 83% / 0.95);
--surface-warning: hsl(56.94 100% 94% / 0.95);
--surface-warning-emphasis: hsl(56.94 100% 82% / 0.95);
--surface-danger: hsl(359 46.6% 94% / 0.95);
--surface-danger-emphasis: hsl(359 46.6% 86% / 0.95);
--surface-hero-start: hsl(172 38% 62%);
--surface-hero-end: hsl(172 46% 50%);
// Border Tokens
--border-soft: hsl(40 18% 80% / 0.7);
--border-strong: hsl(40 18% 80% / 0.85);
--border-field: hsl(40 14% 76% / 0.95);
--border-divider: hsl(40 14% 78% / 0.5);
// Elevation + Focus
--shadow-elevated: 0 20px 45px hsl(40 15% 25% / 0.12);
--shadow-soft: 0 12px 28px hsl(40 15% 25% / 0.08);
--focus-ring-primary: hsl(263 62% 85% / 0.35);
// On-accent / Overlay Tokens
--text-on-accent: hsl(0 0% 100%);
--text-on-accent-muted: hsl(0 0% 100% / 0.82);
--text-accent-strong: hsl(266 40% 30%);
--text-info-strong: hsl(204 70% 34%);
--text-success-strong: hsl(172 55% 28%);
--text-warning-strong: hsl(34 78% 28%);
--text-danger-strong: hsl(359 44% 34%);
--surface-overlay-soft: hsl(0 0% 100% / 0.14);
--surface-overlay-strong: hsl(0 0% 100% / 0.2);
--border-overlay: hsl(0 0% 100% / 0.28);
// Action Tokens
--action-primary-text: var(--gray-50);
--action-primary-start: var(--purple-500);
--action-primary-end: var(--primary);
--action-primary-shadow: 0 12px 24px hsl(263 53% 45% / 0.24);
--action-primary-shadow-hover: 0 16px 30px hsl(263 53% 45% / 0.3);
// Accent
--primary: var(--purple-400);
--primary-hover: var(--purple-500);
--secondary: var(--green-300);
--secondary-hover: var(--green-200);
--warning: var(--red-300);
--warning-hover: var(--red-200);
--info: var(--blue-400);
--info-hover: var(--blue-500);
--success: var(--teal-400);
--success-hover: var(--teal-500);
// Typical Card Button Colors
--bg-1: var(--gray-100);
--bg-1-hover: var(--gray-200);
--bg-2: var(--gray-200);
--bg-2-hover: var(--gray-300);
--bg-3: var(--gray-300);
--bg-3-hover: var(--gray-400);
--box-shadow: 0 8px 16px hsl(0 0% 0% / 0.1);
}
[data-color-scheme="dark"] {
// Background and Text Colors
--bg: var(--gray-900);
--text: var(--gray-50);
--text-muted: var(--gray-400);
// Page Background
--page-glow-primary: hsl(266 38% 16% / 0.34);
--page-glow-secondary: hsl(172 42% 14% / 0.26);
--page-gradient-start: hsl(266 16% 11%);
--page-gradient-end: hsl(40 10% 8%);
// Surface Tokens
--surface-soft: hsl(266 12% 15% / 0.62);
--surface-raised: hsl(266 12% 13% / 0.9);
--surface-field: hsl(266 10% 18% / 0.94);
--surface-field-focus: hsl(266 10% 21% / 0.98);
--surface-accent-soft: hsl(266 22% 20% / 0.9);
--surface-accent-emphasis: hsl(266 24% 26% / 0.9);
--surface-panel: hsl(266 12% 15% / 0.92);
--surface-panel-strong: hsl(266 12% 18% / 0.96);
--surface-info: hsl(204 28% 18% / 0.92);
--surface-info-emphasis: hsl(204 34% 26% / 0.9);
--surface-success: hsl(142 24% 17% / 0.92);
--surface-success-emphasis: hsl(172 26% 24% / 0.9);
--surface-warning: hsl(41 28% 18% / 0.92);
--surface-warning-emphasis: hsl(38 32% 24% / 0.92);
--surface-danger: hsl(359 22% 20% / 0.94);
--surface-danger-emphasis: hsl(359 24% 26% / 0.94);
--surface-hero-start: hsl(172 32% 26%);
--surface-hero-end: hsl(172 38% 20%);
// Border Tokens
--border-soft: hsl(266 10% 24% / 0.72);
--border-strong: hsl(266 10% 26% / 0.9);
--border-field: hsl(266 10% 30% / 0.95);
--border-divider: hsl(266 10% 28% / 0.42);
// Elevation + Focus
--shadow-elevated: 0 20px 45px hsl(0 0% 0% / 0.35);
--shadow-soft: 0 12px 28px hsl(0 0% 0% / 0.22);
--focus-ring-primary: hsl(263 62% 60% / 0.25);
// On-accent / Overlay Tokens
--text-on-accent: hsl(0 0% 100%);
--text-on-accent-muted: hsl(0 0% 100% / 0.78);
--text-accent-strong: hsl(266 68% 78%);
--text-info-strong: hsl(204 78% 76%);
--text-success-strong: hsl(172 60% 72%);
--text-warning-strong: hsl(41 88% 76%);
--text-danger-strong: hsl(359 72% 80%);
--surface-overlay-soft: hsl(0 0% 100% / 0.08);
--surface-overlay-strong: hsl(0 0% 100% / 0.14);
--border-overlay: hsl(0 0% 100% / 0.16);
// Action Tokens
--action-primary-text: var(--gray-50);
--action-primary-start: var(--purple-500);
--action-primary-end: var(--primary);
--action-primary-shadow: 0 12px 24px hsl(263 53% 20% / 0.4);
--action-primary-shadow-hover: 0 16px 30px hsl(263 53% 18% / 0.48);
// Accent
--primary: var(--purple-300);
--primary-hover: var(--purple-200);
--secondary: var(--green-300);
--secondary-hover: var(--green-200);
--info: var(--blue-300);
--info-hover: var(--blue-200);
--success: var(--teal-300);
--success-hover: var(--teal-200);
// Typical Card Button Colors
--bg-1: var(--gray-800);
--bg-1-hover: var(--gray-700);
--bg-2: var(--gray-700);
--bg-2-hover: var(--gray-600);
--bg-3: var(--gray-600);
--bg-3-hover: var(--gray-500);
}
// Breakpoints
$mobile: 768px;
$tablet: 1024px;
$desktop: 1440px;
// The Mixin: Now checks for MIN-width
@mixin respond($breakpoint) {
@if $breakpoint == mobile {
@media (min-width: $mobile) {
@content;
}
} @else if $breakpoint == tablet {
@media (min-width: $tablet) {
@content;
}
} @else if $breakpoint == desktop {
@media (min-width: $desktop) {
@content;
}
}
}
@mixin text-smallest {
font-size: 1rem;
font-size: clamp(1rem, 0.9295774647887324rem + 0.300469483568075vw, 1.2rem);
font-weight: 300;
}
@mixin text-smaller {
font-size: 1.1rem;
font-size: clamp(1.1rem, 0.959154929577465rem + 0.60093896713615vw, 1.5rem);
font-weight: 300;
}
@mixin text-small {
font-size: 1.25rem;
font-size: clamp(1.25rem, 0.9859154929577465rem + 1.1267605633802815vw, 2rem);
font-weight: 300;
}
@mixin text-medium {
font-size: 1.5rem;
font-size: clamp(1.5rem, 1.147887323943662rem + 1.5023474178403755vw, 2.5rem);
font-weight: 400;
}
@mixin text-large {
font-size: 1.75rem;
font-size: clamp(1.75rem, 1.3098591549295775rem + 1.8779342723004695vw, 3rem);
font-weight: 400;
}
@mixin text-largest {
font-size: 2rem;
font-size: clamp(2rem, 1.295774647887324rem + 3.004694835680751vw, 4rem);
font-weight: 400;
}

View File

@@ -14,4 +14,11 @@ export default defineConfig({
server: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "/src/styles/vars" as *; \n`,
},
},
},
});

View File

@@ -24,7 +24,7 @@ guard-dev-tag:
fi
down: ## Stop all servers and clean up temporary files
@DEV_TAG=$${DEV_TAG:-dummy} $(COMPOSE) -f docker-compose.dev.yaml down --remove-orphans
@DEV_TAG=$${DEV_TAG:-dummy} $(COMPOSE) -f docker-compose.dev.yaml down --remove-orphans -v
@rm -rf "$(BACKEND_TMP_DIR)" "$(FRONTEND_DIST_DIR)"
dev: guard-dev-tag ## Start the development server

148
Mock-Data/README.md Normal file
View File

@@ -0,0 +1,148 @@
# Mock Pupil-History Dataset — Learning Path Agent Hackathon
Hand-crafted, internally-consistent JSON dataset for the BoostAI **Learning
Path Agent** hackathon challenge. Mirrors the production SQLAlchemy schema
in `elevenplus-backend/src/app/models/` so an agent built on this data
transfers cleanly to the real database after the hackathon.
**Reference date:** `2026-05-01` (timestamps in the data are relative to
this — adjust `TODAY` in `generate.py` to shift them).
## What's in here
```
classroom.json One Year-6 Maths class + tutor + student-classroom links
students.json 12 students (with _persona annotation per student)
question_bank.json 50 unique 11+ Maths questions
assignments.json 8 assignments (5 CLOSED, 2 PUBLISHED, 1 DRAFT)
assignment_questions.json 64 assignment-question rows (links to question_bank)
assignment_assignees.json 85 (student × assignment) rows with status/scores
student_answers.json 593 per-question answer records — THE primary file
activity_logs.json 593 activity log rows (timestamps, durations, solve_mode)
dataset.json All of the above bundled into one object
generate.py Deterministic generator (re-run anytime)
```
The agent's main input is `dataset.json` (or `student_answers.json` plus
`question_bank.json` if you'd rather load files separately).
## Schema fidelity
Field names and enum values match the production models exactly:
| Production model | This dataset |
|---|---|
| `users.py` (role=student) | `students.json` |
| `classrooms.py` + `classroom_student_rs.py` | `classroom.json` |
| `assignments.py` (status: DRAFT/PUBLISHED/CLOSED) | `assignments.json` |
| `assignment_questions.py` | `assignment_questions.json` |
| `assignment_assignees.py` (status: NOT_STARTED/IN_PROGRESS/SUBMITTED) | `assignment_assignees.json` |
| `question_bank.py` (difficulty: EASY/MEDIUM/HARD; source: BOOST) | `question_bank.json` |
| `student_assignment_answers.py` (graded_marks, marks_awarded, grading_status=GRADED) | `student_answers.json` |
| `assignment_activity_logs.py` (activity_type, duration_seconds, extra_data) | `activity_logs.json` |
| `question_metrics.py` (explanation_type) | embedded as `_solve_mode` on each answer |
Timestamps are **Unix milliseconds** (BigInteger) per the project convention.
### Hackathon annotations (not in production schema)
Fields prefixed with **`_`** are hackathon-only annotations. Strip them if
you ever seed this data into the real DB.
| Annotation | Where | Meaning |
|---|---|---|
| `_persona` | `students.json` | Engineered behaviour persona (see below) |
| `_solve_mode` | `student_answers.json` | One of `just_answer`, `step_by_step`, `solve_together`, `handwritten` |
| `_time_on_task_seconds` | `student_answers.json` | Seconds spent on this question |
| `_is_correct` | `student_answers.json` | Boolean correctness (already implied by `graded_marks`) |
| `_misconception_tag` | `student_answers.json` | Set when wrong answer matches a known misconception (e.g. `add_tops_add_bottoms`) |
| `_question_topic` / `_sub_topic` / `_difficulty` | `student_answers.json` | Denormalised from `question_bank` for convenience |
| `_answered_at` | `student_answers.json` | Same as `created_at`, just clearer name |
## The 12 students
Five students have engineered misconception personas; the other seven are
realistic noise. The agent should ideally identify the personas from the
data itself — `_persona` is included only so you can grade the agent.
| ID | Name | Persona | What you'll see in the data |
|---|---|---|---|
| 201 | Aisha Khan | `fraction_inversion` | ~12% on Fractions Add/Subtract/Multiply, ~78% elsewhere. Wrong answers show **add-tops-add-bottoms** pattern (e.g. ½+⅓ → ⅖). |
| 202 | Ben Carter | `place_value_gaps` | Fails multi-digit subtraction with borrowing & decimal alignment. Strong on single-digit ops. |
| 203 | Chen Wei | `rushed_careless` | Right method when in `step_by_step`; wrong final answer in `just_answer`. Time-on-task drops week-over-week. **No activity in the last 8 days** — also drives Early Warning. |
| 204 | Daniela Rossi | `solve_together_dependent` | Solve-Together share rises **21% → 88%** across the period. Independent accuracy degrading. |
| 205 | Elif Demir | `word_problem_weak` | 0% on word problems, ~90% on bare computation of the same operations. |
| 206 | Felix Brown | `stable_strong` | ~84% overall — baseline noise. |
| 207 | Grace Park | `stable_strong` | ~85% overall — baseline noise. |
| 208210 | Singh / Nakamura / Williams | `stable_mid` | ~65% overall — baseline noise. |
| 211212 | Patel / O'Connor | `stable_weak` | ~50% overall — baseline noise. |
## The 8 assignments
| ID | Name | Topic | Due | Status | Why it matters |
|---|---|---|---|---|---|
| 3001 | HW1 — Place Value Warmup | Place Value | -28d | CLOSED | Baseline data |
| 3002 | HW2 — Arithmetic Practice | Arithmetic | -22d | CLOSED | |
| 3003 | HW3 — Fractions Foundations | Fractions | -16d | CLOSED | First fraction signal |
| 3004 | HW4 — Negatives & BIDMAS | BIDMAS | -10d | CLOSED | |
| 3005 | HW5 — Geometry Basics | Geometry | -6d | CLOSED | |
| 3006 | HW6 — Algebra & Sequences | Algebra | +2d | PUBLISHED | In flight |
| **3007** | **HW7 — Adding Fractions (test prep)** | **Fractions** | **+5d** | **PUBLISHED** | **Curriculum deadline anchor for the bonus EWS** |
| 3008 | HW8 — Mixed Revision | Mixed | +12d | DRAFT | No activity yet |
## Bonus / Early Warning signals embedded in the data
| Signal | Where it lives | Who exhibits it |
|---|---|---|
| Drop in attempt rate (last 7 days) | `student_answers.json` timestamps | Student 203 (no recent activity); 211 reduced volume |
| Increasing Solve-Together reliance | `_solve_mode` distribution over time | Student 204 (21% → 88%) |
| Declining score trend on deadline topic | Fractions accuracy across HW3 → HW7 | Student 201 (very weak on Fractions; HW7 is fractions, due in 5 days) |
| Time since last session | `activity_logs.json` last timestamp | Student 203 (≥ 8 days) |
**Expected agent output for the bonus monitor on this dataset:**
| Rank | Student | Topic driving risk | Suggested action |
|---|---|---|---|
| 1 | 203 Chen Wei | Engagement collapse | Reach out + light re-entry assignment; check for blockers |
| 2 | 201 Aisha Khan | Fractions / Add — HW7 in 5 days | 3 targeted fraction-add questions on the add-tops-add-bottoms misconception |
| 3 | 204 Daniela Rossi | Increasing scaffold dependence (any topic) | Pair with `step_by_step` mode + scheduled `just_answer` checkpoint |
## Running / re-running the generator
```bash
cd boost-ai-eval/mock-data
python3 generate.py
```
The RNG is seeded (`20260501`), so output is byte-stable across runs unless
you change the source. To shift dates, edit `TODAY` at the top of
`generate.py`.
## Quick agent-side recipes
**Load everything:**
```python
import json
data = json.load(open('boost-ai-eval/mock-data/dataset.json'))
students = data['students']
answers = data['student_answers']
qbank = {q['id']: q for q in data['question_bank']}
```
**Pull all answers for a single student:**
```python
def answers_for(student_id):
aa_ids = {aa['id'] for aa in data['assignment_assignees']
if aa['student_id'] == student_id}
return [a for a in data['student_answers'] if a['assignee_id'] in aa_ids]
```
**Compute topic-level mastery snapshot:**
```python
from collections import defaultdict
topic_acc = defaultdict(lambda: [0, 0]) # [correct, total]
for a in answers_for(201):
topic_acc[a['_question_topic']][1] += 1
topic_acc[a['_question_topic']][0] += int(a['_is_correct'])
# {'Fractions': [2, 16], 'Place Value': [3, 4], ...}
```

7646
Mock-Data/activity_logs.json Normal file

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,578 @@
[
{
"id": 300101,
"assignment_id": 3001,
"question_bank_id": 1001,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300102,
"assignment_id": 3001,
"question_bank_id": 1002,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300103,
"assignment_id": 3001,
"question_bank_id": 1003,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300104,
"assignment_id": 3001,
"question_bank_id": 1101,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300105,
"assignment_id": 3001,
"question_bank_id": 1102,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300106,
"assignment_id": 3001,
"question_bank_id": 1301,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300107,
"assignment_id": 3001,
"question_bank_id": 1401,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300108,
"assignment_id": 3001,
"question_bank_id": 1701,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1774605600000
},
{
"id": 300201,
"assignment_id": 3002,
"question_bank_id": 1101,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300202,
"assignment_id": 3002,
"question_bank_id": 1102,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300203,
"assignment_id": 3002,
"question_bank_id": 1103,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300204,
"assignment_id": 3002,
"question_bank_id": 1104,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300205,
"assignment_id": 3002,
"question_bank_id": 1105,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300206,
"assignment_id": 3002,
"question_bank_id": 1106,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300207,
"assignment_id": 3002,
"question_bank_id": 1801,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300208,
"assignment_id": 3002,
"question_bank_id": 1803,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775124000000
},
{
"id": 300301,
"assignment_id": 3003,
"question_bank_id": 1401,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300302,
"assignment_id": 3003,
"question_bank_id": 1402,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300303,
"assignment_id": 3003,
"question_bank_id": 1411,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300304,
"assignment_id": 3003,
"question_bank_id": 1412,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300305,
"assignment_id": 3003,
"question_bank_id": 1413,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300306,
"assignment_id": 3003,
"question_bank_id": 1421,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300307,
"assignment_id": 3003,
"question_bank_id": 1422,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300308,
"assignment_id": 3003,
"question_bank_id": 1802,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1775642400000
},
{
"id": 300401,
"assignment_id": 3004,
"question_bank_id": 1201,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300402,
"assignment_id": 3004,
"question_bank_id": 1202,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300403,
"assignment_id": 3004,
"question_bank_id": 1203,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300404,
"assignment_id": 3004,
"question_bank_id": 1301,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300405,
"assignment_id": 3004,
"question_bank_id": 1302,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300406,
"assignment_id": 3004,
"question_bank_id": 1303,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300407,
"assignment_id": 3004,
"question_bank_id": 1502,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300408,
"assignment_id": 3004,
"question_bank_id": 1701,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776160800000
},
{
"id": 300501,
"assignment_id": 3005,
"question_bank_id": 1601,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300502,
"assignment_id": 3005,
"question_bank_id": 1602,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300503,
"assignment_id": 3005,
"question_bank_id": 1603,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300504,
"assignment_id": 3005,
"question_bank_id": 1611,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300505,
"assignment_id": 3005,
"question_bank_id": 1612,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300506,
"assignment_id": 3005,
"question_bank_id": 1613,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300507,
"assignment_id": 3005,
"question_bank_id": 1804,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300508,
"assignment_id": 3005,
"question_bank_id": 1805,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1776506400000
},
{
"id": 300601,
"assignment_id": 3006,
"question_bank_id": 1501,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300602,
"assignment_id": 3006,
"question_bank_id": 1502,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300603,
"assignment_id": 3006,
"question_bank_id": 1503,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300604,
"assignment_id": 3006,
"question_bank_id": 1511,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300605,
"assignment_id": 3006,
"question_bank_id": 1512,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300606,
"assignment_id": 3006,
"question_bank_id": 1513,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300607,
"assignment_id": 3006,
"question_bank_id": 1804,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300608,
"assignment_id": 3006,
"question_bank_id": 1702,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777197600000
},
{
"id": 300701,
"assignment_id": 3007,
"question_bank_id": 1411,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300702,
"assignment_id": 3007,
"question_bank_id": 1412,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300703,
"assignment_id": 3007,
"question_bank_id": 1413,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300704,
"assignment_id": 3007,
"question_bank_id": 1414,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300705,
"assignment_id": 3007,
"question_bank_id": 1415,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300706,
"assignment_id": 3007,
"question_bank_id": 1416,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300707,
"assignment_id": 3007,
"question_bank_id": 1802,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300708,
"assignment_id": 3007,
"question_bank_id": 1422,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1777456800000
},
{
"id": 300801,
"assignment_id": 3008,
"question_bank_id": 1004,
"question_order": 1,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300802,
"assignment_id": 3008,
"question_bank_id": 1106,
"question_order": 2,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300803,
"assignment_id": 3008,
"question_bank_id": 1303,
"question_order": 3,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300804,
"assignment_id": 3008,
"question_bank_id": 1416,
"question_order": 4,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300805,
"assignment_id": 3008,
"question_bank_id": 1503,
"question_order": 5,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300806,
"assignment_id": 3008,
"question_bank_id": 1613,
"question_order": 6,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300807,
"assignment_id": 3008,
"question_bank_id": 1712,
"question_order": 7,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
},
{
"id": 300808,
"assignment_id": 3008,
"question_bank_id": 1805,
"question_order": 8,
"maximum_marks": 1,
"rubric": null,
"created_at": 1778061600000
}
]

View File

@@ -0,0 +1,98 @@
[
{
"id": 3001,
"name": "HW1 \u2014 Place Value Warmup",
"teacher_id": 100,
"topic": "Place Value",
"due_date": 1775260740000,
"status": "CLOSED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1774605600000,
"updated_at": 1774605600000
},
{
"id": 3002,
"name": "HW2 \u2014 Arithmetic Practice",
"teacher_id": 100,
"topic": "Arithmetic",
"due_date": 1775779140000,
"status": "CLOSED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1775124000000,
"updated_at": 1775124000000
},
{
"id": 3003,
"name": "HW3 \u2014 Fractions Foundations",
"teacher_id": 100,
"topic": "Fractions",
"due_date": 1776297540000,
"status": "CLOSED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1775642400000,
"updated_at": 1775642400000
},
{
"id": 3004,
"name": "HW4 \u2014 Negatives & BIDMAS",
"teacher_id": 100,
"topic": "BIDMAS",
"due_date": 1776815940000,
"status": "CLOSED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1776160800000,
"updated_at": 1776160800000
},
{
"id": 3005,
"name": "HW5 \u2014 Geometry Basics",
"teacher_id": 100,
"topic": "Geometry",
"due_date": 1777161540000,
"status": "CLOSED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1776506400000,
"updated_at": 1776506400000
},
{
"id": 3006,
"name": "HW6 \u2014 Algebra & Sequences",
"teacher_id": 100,
"topic": "Algebra",
"due_date": 1777852740000,
"status": "PUBLISHED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1777197600000,
"updated_at": 1777197600000
},
{
"id": 3007,
"name": "HW7 \u2014 Adding Fractions (test prep)",
"teacher_id": 100,
"topic": "Fractions",
"due_date": 1778111940000,
"status": "PUBLISHED",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1777456800000,
"updated_at": 1777456800000
},
{
"id": 3008,
"name": "HW8 \u2014 Mixed Revision",
"teacher_id": 100,
"topic": "Mixed",
"due_date": 1778716740000,
"status": "DRAFT",
"maximum_marks": 8,
"is_deleted": false,
"created_at": 1778061600000,
"updated_at": 1778061600000
}
]

101
Mock-Data/classroom.json Normal file
View File

@@ -0,0 +1,101 @@
{
"classroom": {
"id": 500,
"name": "Year 6 \u2014 Maths Set 1",
"organization_id": 1,
"tutor_id": 100,
"invite_code": "Y6MATHS1",
"target_level": 6,
"archived": false,
"hide_just_answer": false,
"is_deleted": false,
"created_at": 1772445600000,
"updated_at": 1772445600000
},
"tutor": {
"id": 100,
"fullname": "Sarah Johnson",
"email": "sarah.johnson@boostai.example",
"username": "sjohnson",
"role": "tutor",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1762077600000,
"updated_at": 1762077600000
},
"classroom_student_rs": [
{
"id": 801,
"classroom_id": 500,
"student_id": 201,
"created_at": 1772877600000
},
{
"id": 802,
"classroom_id": 500,
"student_id": 202,
"created_at": 1772877600000
},
{
"id": 803,
"classroom_id": 500,
"student_id": 203,
"created_at": 1772877600000
},
{
"id": 804,
"classroom_id": 500,
"student_id": 204,
"created_at": 1772877600000
},
{
"id": 805,
"classroom_id": 500,
"student_id": 205,
"created_at": 1772877600000
},
{
"id": 806,
"classroom_id": 500,
"student_id": 206,
"created_at": 1772877600000
},
{
"id": 807,
"classroom_id": 500,
"student_id": 207,
"created_at": 1772877600000
},
{
"id": 808,
"classroom_id": 500,
"student_id": 208,
"created_at": 1772877600000
},
{
"id": 809,
"classroom_id": 500,
"student_id": 209,
"created_at": 1772877600000
},
{
"id": 810,
"classroom_id": 500,
"student_id": 210,
"created_at": 1772877600000
},
{
"id": 811,
"classroom_id": 500,
"student_id": 211,
"created_at": 1772877600000
},
{
"id": 812,
"classroom_id": 500,
"student_id": 212,
"created_at": 1772877600000
}
]
}

24386
Mock-Data/dataset.json Normal file

File diff suppressed because it is too large Load Diff

830
Mock-Data/generate.py Normal file
View File

@@ -0,0 +1,830 @@
"""
Mock pupil-history dataset generator for the BoostAI "Learning Path Agent"
hackathon challenge.
Outputs JSON files under the same directory, mirroring the production
SQLAlchemy schema in elevenplus-backend/src/app/models/. The generator is
deterministic (seeded RNG) so re-runs produce identical output.
Run:
python3 generate.py
Outputs:
classroom.json, students.json, question_bank.json, assignments.json,
assignment_questions.json, assignment_assignees.json,
student_answers.json, activity_logs.json, dataset.json
"""
from __future__ import annotations
import json
import os
import random
from datetime import datetime, timedelta, timezone
from pathlib import Path
# Reference date — keep in sync with the hackathon brief.
TODAY = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc)
OUT_DIR = Path(__file__).parent
RNG = random.Random(20260501)
def ms(dt: datetime) -> int:
return int(dt.timestamp() * 1000)
def days_ago(n: float, hour: int = 10, minute: int = 0) -> datetime:
return (TODAY - timedelta(days=n)).replace(hour=hour, minute=minute, second=0, microsecond=0)
# ---------------------------------------------------------------------------
# Classroom + Tutor + Students
# ---------------------------------------------------------------------------
TUTOR = {
"id": 100,
"fullname": "Sarah Johnson",
"email": "sarah.johnson@boostai.example",
"username": "sjohnson",
"role": "tutor",
"active": True,
"is_test": False,
"is_deleted": False,
"created_at": ms(days_ago(180)),
"updated_at": ms(days_ago(180)),
}
CLASSROOM = {
"id": 500,
"name": "Year 6 — Maths Set 1",
"organization_id": 1,
"tutor_id": TUTOR["id"],
"invite_code": "Y6MATHS1",
"target_level": 6,
"archived": False,
"hide_just_answer": False,
"is_deleted": False,
"created_at": ms(days_ago(60)),
"updated_at": ms(days_ago(60)),
}
# 12 students. _persona is a hackathon annotation (not in the production
# schema) — used to drive answer generation below and to document expected
# agent output. Strip the underscore-prefixed fields if seeding the real DB.
STUDENTS_RAW = [
(201, "Aisha Khan", "fraction_inversion"),
(202, "Ben Carter", "place_value_gaps"),
(203, "Chen Wei", "rushed_careless"),
(204, "Daniela Rossi", "solve_together_dependent"),
(205, "Elif Demir", "word_problem_weak"),
(206, "Felix Brown", "stable_strong"),
(207, "Grace Park", "stable_strong"),
(208, "Harry Singh", "stable_mid"),
(209, "Isla Nakamura", "stable_mid"),
(210, "Jaden Williams", "stable_mid"),
(211, "Kira Patel", "stable_weak"),
(212, "Liam O'Connor", "stable_weak"),
]
STUDENTS = []
CLASSROOM_STUDENT_RS = []
for sid, fullname, persona in STUDENTS_RAW:
first = fullname.split()[0].lower().replace("'", "")
STUDENTS.append({
"id": sid,
"fullname": fullname,
"email": f"{first}.{sid}@boostai.example",
"username": f"{first}{sid}",
"role": "student",
"active": True,
"is_test": False,
"is_deleted": False,
"created_at": ms(days_ago(55)),
"updated_at": ms(days_ago(55)),
"_persona": persona,
})
CLASSROOM_STUDENT_RS.append({
"id": 600 + sid,
"classroom_id": CLASSROOM["id"],
"student_id": sid,
"created_at": ms(days_ago(55)),
})
# ---------------------------------------------------------------------------
# Question Bank — Maths, 11+
# Fields mirror question_bank.py.
# _wrong_answer_map: hackathon helper — for misconception personas, the
# answer they typically produce. Not in production schema.
# ---------------------------------------------------------------------------
QUESTION_BANK = [
# ---- Place Value (tens, hundreds, thousands, decimals) ----
{"id": 1001, "topic": "Place Value", "sub_topic": "Multi-digit numbers", "tag": None,
"difficulty": "EASY",
"question_text": "What is the value of the digit 7 in the number 4,732?",
"correct_answer": "700",
"_wrong_answers": {"place_value_gaps": "70"}},
{"id": 1002, "topic": "Place Value", "sub_topic": "Multi-digit numbers", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Round 24,587 to the nearest thousand.",
"correct_answer": "25000",
"_wrong_answers": {"place_value_gaps": "24000"}},
{"id": 1003, "topic": "Place Value", "sub_topic": "Decimals", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Write 0.07 as a fraction in its simplest form.",
"correct_answer": "7/100",
"_wrong_answers": {"place_value_gaps": "7/10"}},
{"id": 1004, "topic": "Place Value", "sub_topic": "Decimals", "tag": None,
"difficulty": "HARD",
"question_text": "What is 0.3 + 0.07?",
"correct_answer": "0.37",
"_wrong_answers": {"place_value_gaps": "0.10"}},
# ---- Arithmetic ----
{"id": 1101, "topic": "Arithmetic", "sub_topic": "Addition", "tag": None,
"difficulty": "EASY",
"question_text": "Calculate 246 + 137.",
"correct_answer": "383",
"_wrong_answers": {"rushed_careless": "373"}},
{"id": 1102, "topic": "Arithmetic", "sub_topic": "Subtraction", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Calculate 503 - 47.",
"correct_answer": "456",
"_wrong_answers": {"place_value_gaps": "544", "rushed_careless": "466"}},
{"id": 1103, "topic": "Arithmetic", "sub_topic": "Subtraction", "tag": None,
"difficulty": "HARD",
"question_text": "Calculate 4,002 - 1,375.",
"correct_answer": "2627",
"_wrong_answers": {"place_value_gaps": "3737", "rushed_careless": "2617"}},
{"id": 1104, "topic": "Arithmetic", "sub_topic": "Multiplication", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Calculate 28 x 7.",
"correct_answer": "196",
"_wrong_answers": {"rushed_careless": "186"}},
{"id": 1105, "topic": "Arithmetic", "sub_topic": "Division", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Calculate 144 / 6.",
"correct_answer": "24",
"_wrong_answers": {"rushed_careless": "26"}},
{"id": 1106, "topic": "Arithmetic", "sub_topic": "Multiplication", "tag": None,
"difficulty": "HARD",
"question_text": "Calculate 156 x 24.",
"correct_answer": "3744",
"_wrong_answers": {"rushed_careless": "3724", "place_value_gaps": "374"}},
# ---- Negative Numbers ----
{"id": 1201, "topic": "Negative Numbers", "sub_topic": "Addition", "tag": None,
"difficulty": "EASY",
"question_text": "What is -5 + 8?",
"correct_answer": "3"},
{"id": 1202, "topic": "Negative Numbers", "sub_topic": "Subtraction", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is 4 - 9?",
"correct_answer": "-5"},
{"id": 1203, "topic": "Negative Numbers", "sub_topic": "Mixed", "tag": None,
"difficulty": "HARD",
"question_text": "What is -7 - (-3)?",
"correct_answer": "-4"},
# ---- BIDMAS ----
{"id": 1301, "topic": "BIDMAS", "sub_topic": None, "tag": None,
"difficulty": "EASY",
"question_text": "Calculate 5 + 3 x 2.",
"correct_answer": "11"},
{"id": 1302, "topic": "BIDMAS", "sub_topic": None, "tag": None,
"difficulty": "MEDIUM",
"question_text": "Calculate (8 - 3) x 4 + 2.",
"correct_answer": "22"},
{"id": 1303, "topic": "BIDMAS", "sub_topic": None, "tag": None,
"difficulty": "HARD",
"question_text": "Calculate 24 / (2 + 4) + 3 x 5.",
"correct_answer": "19",
"_wrong_answers": {"rushed_careless": "17"}},
# ---- Fractions: Equivalent ----
{"id": 1401, "topic": "Fractions", "sub_topic": "Equivalent", "tag": None,
"difficulty": "EASY",
"question_text": "Which fraction is equivalent to 2/4?",
"correct_answer": "1/2"},
{"id": 1402, "topic": "Fractions", "sub_topic": "Equivalent", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Simplify 12/18 to its lowest terms.",
"correct_answer": "2/3"},
{"id": 1403, "topic": "Fractions", "sub_topic": "Equivalent", "tag": None,
"difficulty": "HARD",
"question_text": "Which is larger: 3/5 or 5/8? Give your answer.",
"correct_answer": "5/8"},
# ---- Fractions: Add/Subtract (CORE for fraction_inversion persona) ----
{"id": 1411, "topic": "Fractions", "sub_topic": "Add", "tag": None,
"difficulty": "EASY",
"question_text": "What is 1/2 + 1/3?",
"correct_answer": "5/6",
"_wrong_answers": {"fraction_inversion": "2/5"}},
{"id": 1412, "topic": "Fractions", "sub_topic": "Add", "tag": None,
"difficulty": "EASY",
"question_text": "What is 1/4 + 1/2?",
"correct_answer": "3/4",
"_wrong_answers": {"fraction_inversion": "2/6"}},
{"id": 1413, "topic": "Fractions", "sub_topic": "Add", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is 2/5 + 1/3?",
"correct_answer": "11/15",
"_wrong_answers": {"fraction_inversion": "3/8"}},
{"id": 1414, "topic": "Fractions", "sub_topic": "Add", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is 3/4 + 1/6?",
"correct_answer": "11/12",
"_wrong_answers": {"fraction_inversion": "4/10"}},
{"id": 1415, "topic": "Fractions", "sub_topic": "Subtract", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is 5/6 - 1/3?",
"correct_answer": "1/2",
"_wrong_answers": {"fraction_inversion": "4/3"}},
{"id": 1416, "topic": "Fractions", "sub_topic": "Add", "tag": None,
"difficulty": "HARD",
"question_text": "What is 7/12 + 5/8?",
"correct_answer": "29/24",
"_wrong_answers": {"fraction_inversion": "12/20"}},
# ---- Fractions: Multiply ----
{"id": 1421, "topic": "Fractions", "sub_topic": "Multiply", "tag": None,
"difficulty": "EASY",
"question_text": "What is 1/2 x 1/3?",
"correct_answer": "1/6",
"_wrong_answers": {"fraction_inversion": "2/3"}},
{"id": 1422, "topic": "Fractions", "sub_topic": "Multiply", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is 2/3 x 3/4?",
"correct_answer": "1/2",
"_wrong_answers": {"fraction_inversion": "5/7"}},
{"id": 1423, "topic": "Fractions", "sub_topic": "Multiply", "tag": None,
"difficulty": "HARD",
"question_text": "What is 4/5 of 35?",
"correct_answer": "28",
"_wrong_answers": {"fraction_inversion": "20"}},
# ---- Algebra: Simple Equations ----
{"id": 1501, "topic": "Algebra", "sub_topic": "Simple Equations", "tag": None,
"difficulty": "EASY",
"question_text": "Solve x + 7 = 12.",
"correct_answer": "5"},
{"id": 1502, "topic": "Algebra", "sub_topic": "Simple Equations", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Solve 3x - 4 = 17.",
"correct_answer": "7",
"_wrong_answers": {"rushed_careless": "8"}},
{"id": 1503, "topic": "Algebra", "sub_topic": "Simple Equations", "tag": None,
"difficulty": "HARD",
"question_text": "Solve 2(x + 3) = 18.",
"correct_answer": "6"},
# ---- Algebra: Sequences ----
{"id": 1511, "topic": "Algebra", "sub_topic": "Sequences", "tag": None,
"difficulty": "EASY",
"question_text": "What is the next term: 2, 5, 8, 11, ___ ?",
"correct_answer": "14"},
{"id": 1512, "topic": "Algebra", "sub_topic": "Sequences", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Find the 10th term of the sequence 4, 7, 10, 13, ...",
"correct_answer": "31"},
{"id": 1513, "topic": "Algebra", "sub_topic": "Sequences", "tag": None,
"difficulty": "HARD",
"question_text": "What is the nth term of the sequence 5, 8, 11, 14, ...?",
"correct_answer": "3n+2"},
# ---- Geometry: Area & Perimeter ----
{"id": 1601, "topic": "Geometry", "sub_topic": "Perimeter", "tag": None,
"difficulty": "EASY",
"question_text": "What is the perimeter of a square with side length 7 cm?",
"correct_answer": "28"},
{"id": 1602, "topic": "Geometry", "sub_topic": "Area", "tag": None,
"difficulty": "MEDIUM",
"question_text": "What is the area of a rectangle 8 cm by 5 cm?",
"correct_answer": "40"},
{"id": 1603, "topic": "Geometry", "sub_topic": "Area", "tag": None,
"difficulty": "HARD",
"question_text": "A right-angled triangle has base 6 cm and height 9 cm. What is its area?",
"correct_answer": "27"},
# ---- Geometry: Angles ----
{"id": 1611, "topic": "Geometry", "sub_topic": "Angles", "tag": None,
"difficulty": "EASY",
"question_text": "Two angles on a straight line are 110° and x. What is x?",
"correct_answer": "70"},
{"id": 1612, "topic": "Geometry", "sub_topic": "Angles", "tag": None,
"difficulty": "MEDIUM",
"question_text": "The angles of a triangle are 45°, 60° and x. What is x?",
"correct_answer": "75"},
{"id": 1613, "topic": "Geometry", "sub_topic": "Angles", "tag": None,
"difficulty": "HARD",
"question_text": "What is the sum of interior angles of a hexagon?",
"correct_answer": "720"},
# ---- Data: Mean / Median / Mode ----
{"id": 1701, "topic": "Data", "sub_topic": "Mean", "tag": None,
"difficulty": "EASY",
"question_text": "Find the mean of 4, 6, 8, 10, 12.",
"correct_answer": "8"},
{"id": 1702, "topic": "Data", "sub_topic": "Median", "tag": None,
"difficulty": "MEDIUM",
"question_text": "Find the median of 3, 7, 2, 9, 5.",
"correct_answer": "5"},
{"id": 1703, "topic": "Data", "sub_topic": "Mode", "tag": None,
"difficulty": "EASY",
"question_text": "Find the mode of 2, 3, 3, 5, 7, 7, 7, 8.",
"correct_answer": "7"},
# ---- Data: Probability ----
{"id": 1711, "topic": "Data", "sub_topic": "Probability", "tag": None,
"difficulty": "MEDIUM",
"question_text": "A bag has 3 red and 5 blue marbles. What is the probability of red?",
"correct_answer": "3/8"},
{"id": 1712, "topic": "Data", "sub_topic": "Probability", "tag": None,
"difficulty": "HARD",
"question_text": "A fair die is rolled. What is the probability of an even number greater than 2?",
"correct_answer": "1/3"},
# ---- Word problems (cross-topic) ----
{"id": 1801, "topic": "Arithmetic", "sub_topic": "Word problem", "tag": "word_problem",
"difficulty": "EASY",
"question_text": "Tom has 24 apples. He gives 9 to his friend. How many does he have left?",
"correct_answer": "15"},
{"id": 1802, "topic": "Fractions", "sub_topic": "Word problem", "tag": "word_problem",
"difficulty": "MEDIUM",
"question_text": "A pizza is cut into 8 slices. Sara eats 1/4 and Tom eats 3/8. What fraction is left?",
"correct_answer": "3/8",
"_wrong_answers": {"word_problem_weak": "1/2", "fraction_inversion": "4/12"}},
{"id": 1803, "topic": "Arithmetic", "sub_topic": "Word problem", "tag": "word_problem",
"difficulty": "MEDIUM",
"question_text": "A train ticket costs £8.50. How much do 6 tickets cost?",
"correct_answer": "51",
"_wrong_answers": {"word_problem_weak": "48", "rushed_careless": "50"}},
{"id": 1804, "topic": "Algebra", "sub_topic": "Word problem", "tag": "word_problem",
"difficulty": "HARD",
"question_text": "Three consecutive numbers add up to 72. What is the smallest number?",
"correct_answer": "23",
"_wrong_answers": {"word_problem_weak": "24"}},
{"id": 1805, "topic": "Geometry", "sub_topic": "Word problem", "tag": "word_problem",
"difficulty": "HARD",
"question_text": "A rectangular garden is 12 m long and 4 m shorter than it is long. What is its area?",
"correct_answer": "96",
"_wrong_answers": {"word_problem_weak": "48"}},
]
# Fill in remaining standard fields for every bank entry.
for q in QUESTION_BANK:
q.setdefault("category", "Math")
q.setdefault("year_level", "Year 6")
q.setdefault("source", "BOOST")
q.setdefault("source_description", None)
q.setdefault("teacher_id", TUTOR["id"])
q.setdefault("maximum_marks", 1)
q.setdefault("rubric", None)
q.setdefault("step_by_step_solution", None)
q.setdefault("image_url", None)
q.setdefault("is_deleted", False)
q.setdefault("created_at", ms(days_ago(40)))
q.setdefault("updated_at", ms(days_ago(40)))
q.setdefault("_wrong_answers", {})
# ---------------------------------------------------------------------------
# Assignments + Assignment Questions
#
# 8 assignments. Distribution: 5 CLOSED (past), 2 PUBLISHED (in-flight),
# 1 DRAFT (future). The deadline-pressure assignment is a Fractions/Add
# assignment due in 5 days — drives the bonus Early Warning topic correlation.
# ---------------------------------------------------------------------------
ASSIGNMENT_DEFS = [
# (id, name, focus_topic, due_offset_days, status, question_bank_ids)
(3001, "HW1 — Place Value Warmup", "Place Value", -28, "CLOSED",
[1001, 1002, 1003, 1101, 1102, 1301, 1401, 1701]),
(3002, "HW2 — Arithmetic Practice", "Arithmetic", -22, "CLOSED",
[1101, 1102, 1103, 1104, 1105, 1106, 1801, 1803]),
(3003, "HW3 — Fractions Foundations", "Fractions", -16, "CLOSED",
[1401, 1402, 1411, 1412, 1413, 1421, 1422, 1802]),
(3004, "HW4 — Negatives & BIDMAS", "BIDMAS", -10, "CLOSED",
[1201, 1202, 1203, 1301, 1302, 1303, 1502, 1701]),
(3005, "HW5 — Geometry Basics", "Geometry", -6, "CLOSED",
[1601, 1602, 1603, 1611, 1612, 1613, 1804, 1805]),
(3006, "HW6 — Algebra & Sequences", "Algebra", 2, "PUBLISHED",
[1501, 1502, 1503, 1511, 1512, 1513, 1804, 1702]),
# The deadline-pressure assignment — bonus Early Warning anchors here.
(3007, "HW7 — Adding Fractions (test prep)", "Fractions", 5, "PUBLISHED",
[1411, 1412, 1413, 1414, 1415, 1416, 1802, 1422]),
(3008, "HW8 — Mixed Revision", "Mixed", 12, "DRAFT",
[1004, 1106, 1303, 1416, 1503, 1613, 1712, 1805]),
]
ASSIGNMENTS = []
ASSIGNMENT_QUESTIONS = []
for aid, name, topic, offset, status, qb_ids in ASSIGNMENT_DEFS:
created_offset = max(offset - 7, -45) # created ~1 week before due
ASSIGNMENTS.append({
"id": aid,
"name": name,
"teacher_id": TUTOR["id"],
"topic": topic,
"due_date": ms(days_ago(-offset, hour=23, minute=59)),
"status": status,
"maximum_marks": len(qb_ids),
"is_deleted": False,
"created_at": ms(days_ago(-created_offset)),
"updated_at": ms(days_ago(-created_offset)),
})
for order, qb_id in enumerate(qb_ids, start=1):
ASSIGNMENT_QUESTIONS.append({
"id": aid * 100 + order,
"assignment_id": aid,
"question_bank_id": qb_id,
"question_order": order,
"maximum_marks": 1,
"rubric": None,
"created_at": ms(days_ago(-created_offset)),
})
# ---------------------------------------------------------------------------
# Assignment Assignees (per student × per assignment) + Student Answers
# ---------------------------------------------------------------------------
QB_BY_ID = {q["id"]: q for q in QUESTION_BANK}
AQ_BY_ID = {aq["id"]: aq for aq in ASSIGNMENT_QUESTIONS}
def assignee_status_for(assignment_status: str, persona: str, aid: int) -> str:
if assignment_status == "DRAFT":
return "NOT_STARTED"
if assignment_status == "PUBLISHED":
# Some students have started it, some not.
return "IN_PROGRESS"
return "SUBMITTED"
# --- Persona-driven correctness/solve-mode generation -----------------------
#
# Each persona is a function that, given the assignment, the question, and
# the assignment's "week index" (0 = oldest, higher = more recent), returns:
# (is_correct, solve_mode, time_seconds, answer_text)
# All randomness flows through RNG (seeded), so output is deterministic.
SOLVE_MODES = ["just_answer", "step_by_step", "solve_together", "handwritten"]
def base_time_for_difficulty(d: str) -> int:
return {"EASY": 60, "MEDIUM": 100, "HARD": 160}[d]
def jitter_time(base: int) -> int:
return max(15, int(base + RNG.randint(-25, 35)))
def pick_mode_default(persona: str, week_idx: int) -> str:
# Most students mostly use just_answer; occasionally step_by_step;
# rarely solve_together.
r = RNG.random()
if r < 0.70:
return "just_answer"
if r < 0.90:
return "step_by_step"
if r < 0.97:
return "solve_together"
return "handwritten"
def answer_for_persona(q: dict, persona: str, force_correct: bool) -> tuple[bool, str]:
"""Return (is_correct, answer_text)."""
if force_correct:
return True, q["correct_answer"]
wrong_map = q.get("_wrong_answers", {}) or {}
if persona in wrong_map:
return False, wrong_map[persona]
# Generic wrong answer.
return False, q["correct_answer"] + "?"
def gen_answer(student: dict, assignment: dict, aq: dict, q: dict, week_idx: int, total_weeks: int):
"""Return a student_answers row dict (or None if assignee hasn't attempted it)."""
persona = student["_persona"]
difficulty = q["difficulty"]
base_time = base_time_for_difficulty(difficulty)
is_word = q.get("tag") == "word_problem"
is_fraction_op = q["topic"] == "Fractions" and q["sub_topic"] in ("Add", "Subtract", "Multiply")
is_place_value = q["topic"] == "Place Value" or (q["topic"] == "Arithmetic" and q["sub_topic"] in ("Subtraction", "Multiplication") and difficulty == "HARD")
# Default: stable_mid baseline.
p_correct = 0.65
solve_mode = pick_mode_default(persona, week_idx)
answered_at_offset_days = 0 # set below
misconception_tag = None
if persona == "fraction_inversion":
if is_fraction_op:
# Sharp misconception: very low on fraction ops, declining.
p_correct = max(0.03, 0.20 - 0.03 * week_idx)
misconception_candidate = "add_tops_add_bottoms" if q["sub_topic"] in ("Add", "Subtract") else "fraction_op_confusion"
elif q["topic"] == "Fractions":
# Equivalent fractions etc: still shaky.
p_correct = 0.25
misconception_candidate = "fraction_general_uncertainty"
else:
p_correct = 0.78
misconception_candidate = None
elif persona == "place_value_gaps":
if is_place_value or (q["topic"] == "Place Value"):
p_correct = 0.25
misconception_candidate = "place_value_misalignment"
else:
p_correct = 0.65
misconception_candidate = None
elif persona == "rushed_careless":
# Right method when forced to slow down (step_by_step), wrong when rushed.
# In just_answer: 40% correct. In step_by_step: 90% correct.
# Time-on-task drops over time (rushing more).
# Solve mode mostly just_answer.
r = RNG.random()
solve_mode = "just_answer" if r < 0.85 else "step_by_step"
if solve_mode == "step_by_step":
p_correct = 0.90
else:
p_correct = 0.40
misconception_candidate = "arithmetic_slip"
# Time decays: week 0 = 0.9 * base, latest = 0.4 * base
t_factor = max(0.4, 0.9 - 0.12 * week_idx)
base_time = int(base_time * t_factor)
elif persona == "solve_together_dependent":
# solve_together usage rises sharply over time. Independent
# accuracy is low and degrading — student is leaning on scaffolding
# more and more.
st_prob = 0.08 + 0.18 * week_idx # week 0 ~8%, week 5 ~98%
st_prob = min(0.92, st_prob)
r = RNG.random()
if r < st_prob:
solve_mode = "solve_together"
p_correct = 0.85
else:
solve_mode = "just_answer" if RNG.random() < 0.7 else "step_by_step"
p_correct = max(0.20, 0.55 - 0.06 * week_idx)
misconception_candidate = "scaffolding_dependence"
elif persona == "word_problem_weak":
if is_word:
p_correct = 0.20
misconception_candidate = "word_problem_interpretation"
else:
p_correct = 0.78
misconception_candidate = None
elif persona == "stable_strong":
p_correct = 0.88 if difficulty != "HARD" else 0.78
misconception_candidate = None
elif persona == "stable_mid":
p_correct = 0.65 if difficulty != "HARD" else 0.50
misconception_candidate = None
elif persona == "stable_weak":
p_correct = 0.55 if difficulty != "HARD" else 0.40
misconception_candidate = None
else:
misconception_candidate = None
# Decide attempted-or-not for IN_PROGRESS assignments.
is_correct = RNG.random() < p_correct
is_correct, answer_text = answer_for_persona(q, persona, force_correct=is_correct)
if not is_correct:
misconception_tag = misconception_candidate
return {
"is_correct": is_correct,
"answer_text": answer_text,
"solve_mode": solve_mode,
"time_on_task_seconds": jitter_time(base_time),
"misconception_tag": misconception_tag,
}
# Order assignments oldest -> newest for week_idx threading.
ASSIGNMENT_DEFS_SORTED = sorted(ASSIGNMENT_DEFS, key=lambda a: a[3]) # by due_offset
weeks_total = len(ASSIGNMENT_DEFS_SORTED)
ASSIGNMENT_ASSIGNEES = []
STUDENT_ANSWERS = []
ACTIVITY_LOGS = []
assignee_id_seq = 4000
answer_id_seq = 50000
log_id_seq = 70000
# Define which students "fall behind" on attempts (bonus signal).
LOW_ATTEMPT_RATE_STUDENTS = {203} # Chen Wei: persona=rushed_careless + 8 days no activity
SKIP_RECENT_ASSIGNMENTS_STUDENTS = {203} # student 3 hasn't attempted recent assignments
for week_idx, assignment_def in enumerate(ASSIGNMENT_DEFS_SORTED):
aid, name, topic, offset, status, qb_ids = assignment_def
a_questions = [aq for aq in ASSIGNMENT_QUESTIONS if aq["assignment_id"] == aid]
for student in STUDENTS:
sid = student["id"]
persona = student["_persona"]
# Skip recent assignments for students with low attempt rate
if status != "CLOSED" and sid in SKIP_RECENT_ASSIGNMENTS_STUDENTS:
assignee = {
"id": assignee_id_seq,
"assignment_id": aid,
"student_id": sid,
"classroom_id": CLASSROOM["id"],
"status": "NOT_STARTED",
"started_at": None,
"submitted_at": None,
"total_marks": None,
"is_active": True,
"deactivated_at": None,
"created_at": ms(days_ago(-(offset - 7))),
}
ASSIGNMENT_ASSIGNEES.append(assignee)
assignee_id_seq += 1
continue
# Skip last published assignment for some low-attempt students
if sid in LOW_ATTEMPT_RATE_STUDENTS and status != "CLOSED":
assignee = {
"id": assignee_id_seq,
"assignment_id": aid,
"student_id": sid,
"classroom_id": CLASSROOM["id"],
"status": "NOT_STARTED",
"started_at": None,
"submitted_at": None,
"total_marks": None,
"is_active": True,
"deactivated_at": None,
"created_at": ms(days_ago(-(offset - 7))),
}
ASSIGNMENT_ASSIGNEES.append(assignee)
assignee_id_seq += 1
continue
# In-progress: ~70% have started and answered ~half the questions
if status == "IN_PROGRESS" or status == "PUBLISHED":
started = RNG.random() < 0.85
submitted = False
if not started:
assignee = {
"id": assignee_id_seq,
"assignment_id": aid,
"student_id": sid,
"classroom_id": CLASSROOM["id"],
"status": "NOT_STARTED",
"started_at": None,
"submitted_at": None,
"total_marks": None,
"is_active": True,
"deactivated_at": None,
"created_at": ms(days_ago(-(offset - 7))),
}
ASSIGNMENT_ASSIGNEES.append(assignee)
assignee_id_seq += 1
continue
else:
started = True
submitted = True
# Date the attempts: roughly the day after the assignment was set
attempt_day_offset = max(offset - 5, -42)
started_at = days_ago(-attempt_day_offset, hour=16 + RNG.randint(0, 3), minute=RNG.randint(0, 59))
# For DRAFT, no assignees needed (skip)
if status == "DRAFT":
continue
assignee_id = assignee_id_seq
assignee_id_seq += 1
# Generate answers
total_score = 0
questions_to_answer = a_questions
if status in ("IN_PROGRESS", "PUBLISHED") and not submitted:
# Partial completion
n_done = RNG.randint(max(1, len(a_questions) // 2), len(a_questions))
questions_to_answer = a_questions[:n_done]
running_time_offset = 0
for aq in questions_to_answer:
q = QB_BY_ID[aq["question_bank_id"]]
ans = gen_answer(student, assignment_def, aq, q, week_idx, weeks_total)
answered_at = started_at + timedelta(seconds=running_time_offset + ans["time_on_task_seconds"])
running_time_offset += ans["time_on_task_seconds"] + RNG.randint(5, 30)
STUDENT_ANSWERS.append({
"id": answer_id_seq,
"assignee_id": assignee_id,
"assignment_question_id": aq["id"],
"answer_type": "LATEX",
"answer_latex": ans["answer_text"],
"extracted_answer": ans["answer_text"],
"graded_marks": 1 if ans["is_correct"] else 0,
"marks_awarded": 1 if ans["is_correct"] else 0,
"ai_reasoning": (
"Answer matches expected solution." if ans["is_correct"]
else f"Incorrect; expected {q['correct_answer']}."
),
"grading_status": "GRADED",
"grading_attempts": 1,
"is_active": True,
"created_at": ms(answered_at),
# ---- Hackathon annotations (not in production schema) ----
"_solve_mode": ans["solve_mode"],
"_time_on_task_seconds": ans["time_on_task_seconds"],
"_is_correct": ans["is_correct"],
"_misconception_tag": ans["misconception_tag"],
"_question_topic": q["topic"],
"_question_sub_topic": q["sub_topic"],
"_question_difficulty": q["difficulty"],
"_answered_at": ms(answered_at),
})
answer_id_seq += 1
total_score += (1 if ans["is_correct"] else 0)
# Activity log
ACTIVITY_LOGS.append({
"id": log_id_seq,
"assignee_id": assignee_id,
"assignment_question_id": aq["id"],
"activity_type": "ANSWERED",
"timestamp": ms(answered_at),
"duration_seconds": ans["time_on_task_seconds"],
"extra_data": {"solve_mode": ans["solve_mode"]},
"created_at": ms(answered_at),
"_student_id": sid,
})
log_id_seq += 1
submitted_at = started_at + timedelta(seconds=running_time_offset) if submitted else None
assignee = {
"id": assignee_id,
"assignment_id": aid,
"student_id": sid,
"classroom_id": CLASSROOM["id"],
"status": "SUBMITTED" if submitted else "IN_PROGRESS",
"started_at": ms(started_at),
"submitted_at": ms(submitted_at) if submitted_at else None,
"total_marks": total_score if submitted else None,
"is_active": True,
"deactivated_at": None,
"created_at": ms(days_ago(-(offset - 7))),
}
ASSIGNMENT_ASSIGNEES.append(assignee)
# ---------------------------------------------------------------------------
# Write outputs
# ---------------------------------------------------------------------------
def write_json(name: str, data) -> None:
path = OUT_DIR / name
with path.open("w") as f:
json.dump(data, f, indent=2, default=str)
print(f" wrote {name} ({len(data) if isinstance(data, list) else 'object'} records)")
print("Generating mock dataset...")
write_json("classroom.json", {
"classroom": CLASSROOM,
"tutor": TUTOR,
"classroom_student_rs": CLASSROOM_STUDENT_RS,
})
write_json("students.json", STUDENTS)
write_json("question_bank.json", QUESTION_BANK)
write_json("assignments.json", ASSIGNMENTS)
write_json("assignment_questions.json", ASSIGNMENT_QUESTIONS)
write_json("assignment_assignees.json", ASSIGNMENT_ASSIGNEES)
write_json("student_answers.json", STUDENT_ANSWERS)
write_json("activity_logs.json", ACTIVITY_LOGS)
dataset = {
"_meta": {
"generated_at_utc": TODAY.isoformat(),
"reference_today": TODAY.date().isoformat(),
"schema_source": "elevenplus-backend/src/app/models/",
"subject": "Maths (UK 11+)",
"students": len(STUDENTS),
"assignments": len(ASSIGNMENTS),
"questions_in_bank": len(QUESTION_BANK),
"student_answers": len(STUDENT_ANSWERS),
"expected_top_3_at_risk_student_ids": [201, 203, 204],
},
"classroom": CLASSROOM,
"tutor": TUTOR,
"classroom_student_rs": CLASSROOM_STUDENT_RS,
"students": STUDENTS,
"question_bank": QUESTION_BANK,
"assignments": ASSIGNMENTS,
"assignment_questions": ASSIGNMENT_QUESTIONS,
"assignment_assignees": ASSIGNMENT_ASSIGNEES,
"student_answers": STUDENT_ANSWERS,
"activity_logs": ACTIVITY_LOGS,
}
write_json("dataset.json", dataset)
print("Done.")

1157
Mock-Data/question_bank.json Normal file

File diff suppressed because it is too large Load Diff

13526
Mock-Data/student_answers.json Normal file

File diff suppressed because it is too large Load Diff

158
Mock-Data/students.json Normal file
View File

@@ -0,0 +1,158 @@
[
{
"id": 201,
"fullname": "Aisha Khan",
"email": "aisha.201@boostai.example",
"username": "aisha201",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "fraction_inversion"
},
{
"id": 202,
"fullname": "Ben Carter",
"email": "ben.202@boostai.example",
"username": "ben202",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "place_value_gaps"
},
{
"id": 203,
"fullname": "Chen Wei",
"email": "chen.203@boostai.example",
"username": "chen203",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "rushed_careless"
},
{
"id": 204,
"fullname": "Daniela Rossi",
"email": "daniela.204@boostai.example",
"username": "daniela204",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "solve_together_dependent"
},
{
"id": 205,
"fullname": "Elif Demir",
"email": "elif.205@boostai.example",
"username": "elif205",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "word_problem_weak"
},
{
"id": 206,
"fullname": "Felix Brown",
"email": "felix.206@boostai.example",
"username": "felix206",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_strong"
},
{
"id": 207,
"fullname": "Grace Park",
"email": "grace.207@boostai.example",
"username": "grace207",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_strong"
},
{
"id": 208,
"fullname": "Harry Singh",
"email": "harry.208@boostai.example",
"username": "harry208",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_mid"
},
{
"id": 209,
"fullname": "Isla Nakamura",
"email": "isla.209@boostai.example",
"username": "isla209",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_mid"
},
{
"id": 210,
"fullname": "Jaden Williams",
"email": "jaden.210@boostai.example",
"username": "jaden210",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_mid"
},
{
"id": 211,
"fullname": "Kira Patel",
"email": "kira.211@boostai.example",
"username": "kira211",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_weak"
},
{
"id": 212,
"fullname": "Liam O'Connor",
"email": "liam.212@boostai.example",
"username": "liam212",
"role": "student",
"active": true,
"is_test": false,
"is_deleted": false,
"created_at": 1772877600000,
"updated_at": 1772877600000,
"_persona": "stable_weak"
}
]

View File

@@ -1 +1,11 @@
# BoostAI
This data is stored at question level, topic level, and session level
a sequenced list of 10 questions with a plain-English justification for each choice, covering difficulty gradient, question type, and topic rationale. The agent must reason about misconception patterns
and must be able to re-route mid-session when a pupil answers unexpectedly well or poorly
attempt records, solve mode (independent / step-by-step / Solve Together / handwritten), time on task, score per question, topic and subtopic tags, class ave. score for that topic
Output must be structured JSON with question ID, topic, difficulty level, and a plain-English justification for every item in the plan

View File

@@ -18,6 +18,7 @@ services:
restart: unless-stopped
volumes:
- ./Frontend:/app
- ./Mock-Data:/Mock-Data
- BoostAI_node_modules:/app/node_modules
command: ["pnpm", "dev", "--host", "0.0.0.0", "--port", "4321"]
environment: