Refactor project structure and integrate Redux for state management. Update configuration files for new project name and add Webpack plugins for environment variables. Implement user authentication with Keycloak and create a context for challenge management. Add various components for user interaction, including dashboards and task workspaces. Enhance API integration and add error handling utilities. Introduce analytics and polling mechanisms for improved user experience.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
This commit is contained in:
parent
3a65307fd0
commit
624280ab5e
24
@types/index.d.ts
vendored
Normal file
24
@types/index.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
declare const IS_PROD: string
|
||||||
|
declare const KC_URL: string
|
||||||
|
declare const KC_REALM: string
|
||||||
|
declare const KC_CLIENT_ID: string
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const svg_path: string
|
||||||
|
|
||||||
|
export default svg_path
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const jpg_path: string
|
||||||
|
|
||||||
|
export default value
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const png_path: string
|
||||||
|
|
||||||
|
export default value
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __webpack_public_path__: string
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
const pkg = require('./package')
|
const pkg = require('./package')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -5,19 +7,25 @@ module.exports = {
|
|||||||
webpackConfig: {
|
webpackConfig: {
|
||||||
output: {
|
output: {
|
||||||
publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`
|
publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`
|
||||||
}
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
KC_URL: process.env.KC_URL || '"https://auth.brojs.ru"',
|
||||||
|
KC_REALM: process.env.KC_REALM || '"itpark"',
|
||||||
|
KC_CLIENT_ID: process.env.KC_CLIENT_ID || '"journal"',
|
||||||
|
}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
/* use https://admin.bro-js.ru/ to create config, navigations and features */
|
/* use https://admin.bro-js.ru/ to create config, navigations and features */
|
||||||
navigations: {
|
navigations: {
|
||||||
'challenge-pl.main': '/challenge-pl',
|
'challenge.main': '/challenge',
|
||||||
'link.challenge-pl.auth': '/auth'
|
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
'challenge-pl': {
|
'challenge': {
|
||||||
// add your features here in the format [featureName]: { value: string }
|
// add your features here in the format [featureName]: { value: string }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
'challenge-pl.api': '/api'
|
'challenge.api': '/api'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
locales/ru.json
Normal file
1
locales/ru.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
123
package-lock.json
generated
123
package-lock.json
generated
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "challenge-pl",
|
"name": "challenge",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "challenge-pl",
|
"name": "challenge",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"@chakra-ui/react": "^3.2.0",
|
"@chakra-ui/react": "^3.2.0",
|
||||||
"@emotion/react": "^11.13.5",
|
"@emotion/react": "^11.13.5",
|
||||||
"@eslint/js": "^9.11.0",
|
"@eslint/js": "^9.11.0",
|
||||||
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"@stylistic/eslint-plugin": "^2.8.0",
|
"@stylistic/eslint-plugin": "^2.8.0",
|
||||||
"@types/node": "^22.18.13",
|
"@types/node": "^22.18.13",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
@ -21,8 +22,10 @@
|
|||||||
"eslint-plugin-react": "^7.36.1",
|
"eslint-plugin-react": "^7.36.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"keycloak-js": "^26.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"typescript-eslint": "^8.6.0"
|
"typescript-eslint": "^8.6.0"
|
||||||
}
|
}
|
||||||
@ -2501,6 +2504,42 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
|
||||||
|
"integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^10.0.3",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz",
|
||||||
@ -2510,6 +2549,18 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@stylistic/eslint-plugin": {
|
"node_modules/@stylistic/eslint-plugin": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.8.0.tgz",
|
||||||
@ -2626,6 +2677,12 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/webpack-env": {
|
"node_modules/@types/webpack-env": {
|
||||||
"version": "1.18.8",
|
"version": "1.18.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz",
|
||||||
@ -8135,6 +8192,15 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/keycloak-js": {
|
||||||
|
"version": "26.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"workspaces": [
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/keygrip": {
|
"node_modules/keygrip": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||||
@ -9481,6 +9547,29 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.23.1",
|
"version": "6.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz",
|
||||||
@ -9583,6 +9672,21 @@
|
|||||||
"recursive-watch": "bin.js"
|
"recursive-watch": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -9687,6 +9791,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -11117,6 +11227,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "challenge-pl",
|
"name": "challenge",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./src/index.tsx",
|
"main": "./src/index.tsx",
|
||||||
@ -24,6 +24,7 @@
|
|||||||
"@chakra-ui/react": "^3.2.0",
|
"@chakra-ui/react": "^3.2.0",
|
||||||
"@emotion/react": "^11.13.5",
|
"@emotion/react": "^11.13.5",
|
||||||
"@eslint/js": "^9.11.0",
|
"@eslint/js": "^9.11.0",
|
||||||
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"@stylistic/eslint-plugin": "^2.8.0",
|
"@stylistic/eslint-plugin": "^2.8.0",
|
||||||
"@types/node": "^22.18.13",
|
"@types/node": "^22.18.13",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
@ -32,8 +33,10 @@
|
|||||||
"eslint-plugin-react": "^7.36.1",
|
"eslint-plugin-react": "^7.36.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"keycloak-js": "^26.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"typescript-eslint": "^8.6.0"
|
"typescript-eslint": "^8.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/__data__/api/api.ts
Normal file
139
src/__data__/api/api.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||||
|
import { getConfigValue } from '@brojs/cli'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChallengeAuthResponse,
|
||||||
|
ChallengeChain,
|
||||||
|
ChallengeSubmitPayload,
|
||||||
|
ChallengeSubmitResponse,
|
||||||
|
ChallengeSubmission,
|
||||||
|
ChallengeTask,
|
||||||
|
QueueStatus,
|
||||||
|
SystemStats,
|
||||||
|
UserStats,
|
||||||
|
} from '../types'
|
||||||
|
import { keycloak } from '../kc'
|
||||||
|
|
||||||
|
const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url)
|
||||||
|
const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api'))
|
||||||
|
const challengeBaseUrl = `${backendBaseUrl}/challenge`
|
||||||
|
|
||||||
|
export const api = createApi({
|
||||||
|
reducerPath: 'challengeApi',
|
||||||
|
baseQuery: fetchBaseQuery({
|
||||||
|
baseUrl: challengeBaseUrl,
|
||||||
|
fetchFn: async (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(input, init)
|
||||||
|
|
||||||
|
if (response.status === 403) keycloak.login()
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
prepareHeaders: (headers) => {
|
||||||
|
headers.set('Content-Type', 'application/json;charset=utf-8')
|
||||||
|
|
||||||
|
if (keycloak?.token) {
|
||||||
|
headers.set('Authorization', `Bearer ${keycloak.token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tagTypes: ['Chains', 'Chain', 'UserStats', 'SystemStats', 'Submissions', 'Queue'],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
authUser: builder.mutation<ChallengeAuthResponse, { nickname: string }>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/auth',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
getChains: builder.query<ChallengeChain[], void>({
|
||||||
|
query: () => ({
|
||||||
|
url: '/chains',
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['Chains'],
|
||||||
|
}),
|
||||||
|
getChain: builder.query<ChallengeChain, string>({
|
||||||
|
query: (chainId) => ({
|
||||||
|
url: `/chain/${chainId}`,
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: (_result, _error, arg) => [{ type: 'Chain', id: arg }],
|
||||||
|
}),
|
||||||
|
submitSolution: builder.mutation<ChallengeSubmitResponse, ChallengeSubmitPayload>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/submit',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Queue', 'Submissions', 'UserStats'],
|
||||||
|
}),
|
||||||
|
checkQueueStatus: builder.query<QueueStatus, string>({
|
||||||
|
query: (queueId) => ({
|
||||||
|
url: `/check-status/${queueId}`,
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: (_result, _error, arg) => [{ type: 'Queue', id: arg }],
|
||||||
|
}),
|
||||||
|
getUserStats: builder.query<UserStats, string>({
|
||||||
|
query: (userId) => ({
|
||||||
|
url: `/user/${userId}/stats`,
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: (_result, _error, arg) => [{ type: 'UserStats', id: arg }],
|
||||||
|
}),
|
||||||
|
getUserSubmissions: builder.query<ChallengeSubmission[], { userId: string; taskId?: string }>({
|
||||||
|
query: ({ userId, taskId }) => ({
|
||||||
|
url: `/user/${userId}/submissions${taskId ? `?taskId=${taskId}` : ''}`,
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: (_result, _error, arg) => [{ type: 'Submissions', id: arg.userId }],
|
||||||
|
}),
|
||||||
|
getSystemStats: builder.query<SystemStats, void>({
|
||||||
|
query: () => ({
|
||||||
|
url: '/stats',
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['SystemStats'],
|
||||||
|
}),
|
||||||
|
getTask: builder.query<ChallengeTask, string>({
|
||||||
|
query: (taskId) => ({
|
||||||
|
url: `/task/${taskId}`,
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: (_result, _error, arg) => [{ type: 'Submissions', id: `task-${arg}` }],
|
||||||
|
}),
|
||||||
|
getAllSubmissions: builder.query<ChallengeSubmission[], void>({
|
||||||
|
query: () => ({
|
||||||
|
url: '/submissions',
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['Submissions'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useAuthUserMutation,
|
||||||
|
useGetChainsQuery,
|
||||||
|
useLazyGetChainsQuery,
|
||||||
|
useGetChainQuery,
|
||||||
|
useSubmitSolutionMutation,
|
||||||
|
useCheckQueueStatusQuery,
|
||||||
|
useLazyCheckQueueStatusQuery,
|
||||||
|
useGetUserStatsQuery,
|
||||||
|
useLazyGetUserStatsQuery,
|
||||||
|
useGetUserSubmissionsQuery,
|
||||||
|
useLazyGetUserSubmissionsQuery,
|
||||||
|
useGetSystemStatsQuery,
|
||||||
|
useLazyGetSystemStatsQuery,
|
||||||
|
useGetTaskQuery,
|
||||||
|
useLazyGetTaskQuery,
|
||||||
|
useGetAllSubmissionsQuery,
|
||||||
|
useLazyGetAllSubmissionsQuery,
|
||||||
|
} = api
|
||||||
7
src/__data__/kc.ts
Normal file
7
src/__data__/kc.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import Keycloak from 'keycloak-js'
|
||||||
|
|
||||||
|
export const keycloak = new Keycloak({
|
||||||
|
url: KC_URL,
|
||||||
|
realm: KC_REALM,
|
||||||
|
clientId: KC_CLIENT_ID,
|
||||||
|
})
|
||||||
10
src/__data__/slices/user.ts
Normal file
10
src/__data__/slices/user.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
import { UserData } from '../types'
|
||||||
|
|
||||||
|
export const userSlice = createSlice({
|
||||||
|
name: 'user',
|
||||||
|
initialState: null as UserData,
|
||||||
|
reducers: {
|
||||||
|
}
|
||||||
|
})
|
||||||
24
src/__data__/store.ts
Normal file
24
src/__data__/store.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
import { api } from './api/api'
|
||||||
|
|
||||||
|
export const createStore = (preloadedState = {}) =>
|
||||||
|
configureStore({
|
||||||
|
preloadedState,
|
||||||
|
reducer: {
|
||||||
|
[api.reducerPath]: api.reducer,
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
immutableCheck: false,
|
||||||
|
serializableCheck: false,
|
||||||
|
}).concat(api.middleware),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppStore = ReturnType<typeof createStore>
|
||||||
|
export type RootState = ReturnType<AppStore['getState']>
|
||||||
|
export type AppDispatch = AppStore['dispatch']
|
||||||
|
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>()
|
||||||
399
src/__data__/types.ts
Normal file
399
src/__data__/types.ts
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
|
||||||
|
|
||||||
|
export interface ChallengeUser {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeTask {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
hiddenInstructions?: string
|
||||||
|
creator?: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeChain {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tasks: ChallengeTask[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeSubmission {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
user: ChallengeUser | string
|
||||||
|
task: ChallengeTask | string
|
||||||
|
result: string
|
||||||
|
status: SubmissionStatus
|
||||||
|
queueId?: string
|
||||||
|
feedback?: string
|
||||||
|
submittedAt: string
|
||||||
|
checkedAt?: string
|
||||||
|
attemptNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
status: QueueStatusType
|
||||||
|
submission?: ChallengeSubmission
|
||||||
|
error?: string
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskAttempt {
|
||||||
|
attemptNumber: number
|
||||||
|
status: SubmissionStatus
|
||||||
|
submittedAt: string
|
||||||
|
checkedAt?: string
|
||||||
|
feedback?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStats {
|
||||||
|
taskId: string
|
||||||
|
taskTitle: string
|
||||||
|
attempts: TaskAttempt[]
|
||||||
|
totalAttempts: number
|
||||||
|
status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
|
||||||
|
lastAttemptAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainStats {
|
||||||
|
chainId: string
|
||||||
|
chainName: string
|
||||||
|
totalTasks: number
|
||||||
|
completedTasks: number
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
totalTasksAttempted: number
|
||||||
|
completedTasks: number
|
||||||
|
inProgressTasks: number
|
||||||
|
needsRevisionTasks: number
|
||||||
|
totalSubmissions: number
|
||||||
|
averageCheckTimeMs: number
|
||||||
|
taskStats: TaskStats[]
|
||||||
|
chainStats: ChainStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
users: number
|
||||||
|
tasks: number
|
||||||
|
chains: number
|
||||||
|
submissions: {
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
rejected: number
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
}
|
||||||
|
averageCheckTimeMs: number
|
||||||
|
queue: {
|
||||||
|
queueLength: number
|
||||||
|
waiting: number
|
||||||
|
inProgress: number
|
||||||
|
maxConcurrency: number
|
||||||
|
currentlyProcessing: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceMetrics {
|
||||||
|
timeToFeedback: number
|
||||||
|
queueWaitTime: number
|
||||||
|
checkTime: number
|
||||||
|
initialQueuePosition: number
|
||||||
|
pollsBeforeComplete: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorMetrics {
|
||||||
|
timeSpentOnTask: number
|
||||||
|
solutionLength: number
|
||||||
|
editCount: number
|
||||||
|
usedDraft: boolean
|
||||||
|
timeToSubmit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessMetrics {
|
||||||
|
firstAttemptSuccessRate: number
|
||||||
|
averageAttemptsToSuccess: number
|
||||||
|
chainCompletionRate: number
|
||||||
|
timeToFirstSuccess: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboardOverview {
|
||||||
|
tasksCompleted: number
|
||||||
|
totalTasks: number
|
||||||
|
completionPercentage: number
|
||||||
|
currentStreak: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboardChain {
|
||||||
|
chainId: string
|
||||||
|
name: string
|
||||||
|
progress: number
|
||||||
|
nextTask: ChallengeTask | null
|
||||||
|
estimatedTimeToComplete: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboardAchievement {
|
||||||
|
type: 'task_completed' | 'chain_completed' | 'first_try_success'
|
||||||
|
taskTitle: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboardAttemptsStats {
|
||||||
|
totalAttempts: number
|
||||||
|
successfulAttempts: number
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboardRecommendation {
|
||||||
|
type: 'retry' | 'continue' | 'new_chain'
|
||||||
|
message: string
|
||||||
|
actionLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalDashboard {
|
||||||
|
overview: PersonalDashboardOverview
|
||||||
|
activeChains: PersonalDashboardChain[]
|
||||||
|
recentAchievements: PersonalDashboardAchievement[]
|
||||||
|
attemptsStats: PersonalDashboardAttemptsStats
|
||||||
|
recommendations: PersonalDashboardRecommendation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardQueueStatus {
|
||||||
|
length: number
|
||||||
|
processing: number
|
||||||
|
avgWaitTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardTaskMetric {
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
attemptsCount: number
|
||||||
|
successRate: number
|
||||||
|
avgAttempts: number
|
||||||
|
avgTimeToComplete: number
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardIssue {
|
||||||
|
type: 'low_success_rate' | 'high_attempts' | 'long_queue'
|
||||||
|
severity: 'low' | 'medium' | 'high'
|
||||||
|
message: string
|
||||||
|
affectedEntity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardUserActivity {
|
||||||
|
registrationsToday: number
|
||||||
|
submissionsToday: number
|
||||||
|
peakHours: Array<{ hour: number; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardData {
|
||||||
|
system: {
|
||||||
|
totalUsers: number
|
||||||
|
activeUsers24h: number
|
||||||
|
totalTasks: number
|
||||||
|
totalChains: number
|
||||||
|
queueStatus: AdminDashboardQueueStatus
|
||||||
|
}
|
||||||
|
taskMetrics: AdminDashboardTaskMetric[]
|
||||||
|
userActivity: AdminDashboardUserActivity
|
||||||
|
issues: AdminDashboardIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressChartData {
|
||||||
|
completed: number
|
||||||
|
inProgress: number
|
||||||
|
needsRevision: number
|
||||||
|
notStarted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineDataPoint {
|
||||||
|
timestamp: string
|
||||||
|
checkTime: number
|
||||||
|
status: 'accepted' | 'needs_revision'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineChartData {
|
||||||
|
submissions: TimelineDataPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapDayData {
|
||||||
|
date: string
|
||||||
|
submissions: number
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapData {
|
||||||
|
dates: HeatmapDayData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileDashboard {
|
||||||
|
quickStats: {
|
||||||
|
completedToday: number
|
||||||
|
currentStreak: number
|
||||||
|
nextTask: string
|
||||||
|
}
|
||||||
|
weekProgress: number[]
|
||||||
|
quickActions: Array<{
|
||||||
|
label: string
|
||||||
|
action: () => void
|
||||||
|
icon: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: number | string
|
||||||
|
change?: number
|
||||||
|
trend?: 'up' | 'down'
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeAuthResponse {
|
||||||
|
ok: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeSubmitPayload {
|
||||||
|
userId: string
|
||||||
|
taskId: string
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeSubmitResponse {
|
||||||
|
queueId: string
|
||||||
|
submissionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeEvent<T = unknown> {
|
||||||
|
type: string
|
||||||
|
timestamp: string
|
||||||
|
userId: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ABTestMetrics {
|
||||||
|
variant: 'A' | 'B'
|
||||||
|
submissionRate: number
|
||||||
|
completionRate: number
|
||||||
|
retryRate: number
|
||||||
|
timeToFirstSubmission: number
|
||||||
|
sessionDuration: number
|
||||||
|
satisfactionScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные токена аутентификации
|
||||||
|
*/
|
||||||
|
interface TokenData {
|
||||||
|
/** Время истечения токена */
|
||||||
|
exp: number;
|
||||||
|
/** Время выдачи токена */
|
||||||
|
iat: number;
|
||||||
|
/** Время аутентификации */
|
||||||
|
auth_time: number;
|
||||||
|
/** Уникальный идентификатор токена */
|
||||||
|
jti: string;
|
||||||
|
/** Издатель токена */
|
||||||
|
iss: string;
|
||||||
|
/** Аудитория токена */
|
||||||
|
aud: string[];
|
||||||
|
/** Идентификатор пользователя */
|
||||||
|
sub: string;
|
||||||
|
/** Тип токена */
|
||||||
|
typ: string;
|
||||||
|
/** Идентификатор клиента */
|
||||||
|
azp: string;
|
||||||
|
/** Одноразовое значение */
|
||||||
|
nonce: string;
|
||||||
|
/** Состояние сессии */
|
||||||
|
session_state: string;
|
||||||
|
/** Уровень аутентификации */
|
||||||
|
acr: string;
|
||||||
|
/** Разрешенные источники */
|
||||||
|
"allowed-origins": string[];
|
||||||
|
/** Доступ к области */
|
||||||
|
realm_access: Realmaccess;
|
||||||
|
/** Доступ к ресурсам */
|
||||||
|
resource_access: Resourceaccess;
|
||||||
|
/** Область действия токена */
|
||||||
|
scope: string;
|
||||||
|
/** Идентификатор сессии */
|
||||||
|
sid: string;
|
||||||
|
/** Подтвержден ли email */
|
||||||
|
email_verified: boolean;
|
||||||
|
/** Полное имя пользователя */
|
||||||
|
name: string;
|
||||||
|
/** Предпочитаемое имя пользователя */
|
||||||
|
preferred_username: string;
|
||||||
|
/** Имя пользователя */
|
||||||
|
given_name: string;
|
||||||
|
/** Фамилия пользователя */
|
||||||
|
family_name: string;
|
||||||
|
/** Email пользователя */
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доступ к ресурсам
|
||||||
|
*/
|
||||||
|
interface Resourceaccess {
|
||||||
|
/** Доступ к журналу */
|
||||||
|
journal: Realmaccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доступ к области
|
||||||
|
*/
|
||||||
|
interface Realmaccess {
|
||||||
|
/** Роли пользователя */
|
||||||
|
roles: (string | "teacher")[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширенные данные пользователя
|
||||||
|
*/
|
||||||
|
export interface UserData extends TokenData {
|
||||||
|
/** Идентификатор пользователя */
|
||||||
|
sub: string;
|
||||||
|
/** URL аватара пользователя */
|
||||||
|
gravatar: string;
|
||||||
|
/** Подтвержден ли email */
|
||||||
|
email_verified: boolean;
|
||||||
|
/** Дополнительные атрибуты пользователя */
|
||||||
|
attributes: Record<string, string[]>;
|
||||||
|
/** Полное имя пользователя */
|
||||||
|
name: string;
|
||||||
|
/** Предпочитаемое имя пользователя */
|
||||||
|
preferred_username: string;
|
||||||
|
/** Имя пользователя */
|
||||||
|
given_name: string;
|
||||||
|
/** Фамилия пользователя */
|
||||||
|
family_name: string;
|
||||||
|
/** Email пользователя */
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый ответ API
|
||||||
|
*/
|
||||||
|
export type BaseResponse<Data> = {
|
||||||
|
/** Успешность операции */
|
||||||
|
success: boolean;
|
||||||
|
/** Данные ответа */
|
||||||
|
body: Data;
|
||||||
|
};
|
||||||
|
|
||||||
@ -4,12 +4,25 @@ import pkg from '../../package.json'
|
|||||||
|
|
||||||
const baseUrl = getNavigationValue(`${pkg.name}.main`)
|
const baseUrl = getNavigationValue(`${pkg.name}.main`)
|
||||||
const navs = getNavigation()
|
const navs = getNavigation()
|
||||||
const makeUrl = (url) => baseUrl + url
|
const normalizePath = (path?: string) => {
|
||||||
|
if (!path) return ''
|
||||||
|
return path.startsWith('/') ? path : `/${path}`
|
||||||
|
}
|
||||||
|
const makeUrl = (url?: string) => `${baseUrl}${normalizePath(url)}`
|
||||||
|
|
||||||
|
const getNavPath = (key: string, fallback: string) => {
|
||||||
|
const value = navs[key]
|
||||||
|
return value ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
export const URLs = {
|
export const URLs = {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
auth: {
|
auth: {
|
||||||
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
||||||
isOn: Boolean(navs[`link.${pkg.name}.auth`])
|
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
url: makeUrl(getNavPath(`link.${pkg.name}.admin`, '/admin')),
|
||||||
|
isOn: Boolean(navs[`link.${pkg.name}.admin`] ?? true),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app.tsx
27
src/app.tsx
@ -1,17 +1,30 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { Provider as ReduxProvider } from 'react-redux'
|
||||||
|
|
||||||
import { Dashboard } from './dashboard'
|
import { Dashboard } from './dashboard'
|
||||||
import { Provider } from './theme'
|
import { Provider as ThemeProvider } from './theme'
|
||||||
|
import { ChallengeProvider } from './context/ChallengeContext'
|
||||||
|
import { createStore, type AppStore } from './__data__/store'
|
||||||
|
|
||||||
const App = () => {
|
interface AppProps {
|
||||||
return (
|
store?: AppStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const App = ({ store }: AppProps) => {
|
||||||
|
const resolvedStore = useMemo(() => store ?? createStore(), [store])
|
||||||
|
|
||||||
|
const content = (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Provider>
|
<ThemeProvider>
|
||||||
<Dashboard />
|
<ChallengeProvider>
|
||||||
</Provider>
|
<Dashboard />
|
||||||
|
</ChallengeProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return <ReduxProvider store={resolvedStore}>{content}</ReduxProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
200
src/components/admin/ABTestPanel.tsx
Normal file
200
src/components/admin/ABTestPanel.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
Heading,
|
||||||
|
NumberInput,
|
||||||
|
NumberInputInput,
|
||||||
|
Stat,
|
||||||
|
StatHelpText,
|
||||||
|
StatLabel,
|
||||||
|
StatValueText,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ABTestMetrics } from '../../__data__/types'
|
||||||
|
import { compareVariants } from '../../utils/analytics'
|
||||||
|
|
||||||
|
interface VariantFormState {
|
||||||
|
submissionRate: number
|
||||||
|
completionRate: number
|
||||||
|
retryRate: number
|
||||||
|
timeToFirstSubmission: number
|
||||||
|
sessionDuration: number
|
||||||
|
satisfactionScore?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVariantState = (): VariantFormState => ({
|
||||||
|
submissionRate: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
retryRate: 0,
|
||||||
|
timeToFirstSubmission: 0,
|
||||||
|
sessionDuration: 0,
|
||||||
|
satisfactionScore: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildMetrics = (variant: 'A' | 'B', state: VariantFormState): ABTestMetrics => ({
|
||||||
|
variant,
|
||||||
|
submissionRate: state.submissionRate,
|
||||||
|
completionRate: state.completionRate,
|
||||||
|
retryRate: state.retryRate,
|
||||||
|
timeToFirstSubmission: state.timeToFirstSubmission,
|
||||||
|
sessionDuration: state.sessionDuration,
|
||||||
|
satisfactionScore: state.satisfactionScore,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MetricInput = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
suffix?: string
|
||||||
|
}) => (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<NumberInput value={value} min={0} onChange={(_, val) => onChange(Number.isNaN(val) ? 0 : val)}>
|
||||||
|
<NumberInputInput />
|
||||||
|
</NumberInput>
|
||||||
|
{suffix && (
|
||||||
|
<StatHelpText fontSize="xs" color="gray.500">
|
||||||
|
{suffix}
|
||||||
|
</StatHelpText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ABTestPanel = () => {
|
||||||
|
const [variantA, setVariantA] = useState<VariantFormState>(createVariantState)
|
||||||
|
const [variantB, setVariantB] = useState<VariantFormState>(createVariantState)
|
||||||
|
const [comparison, setComparison] = useState<ReturnType<typeof compareVariants> | null>(null)
|
||||||
|
|
||||||
|
const handleCompare = () => {
|
||||||
|
const metricsA = buildMetrics('A', variantA)
|
||||||
|
const metricsB = buildMetrics('B', variantB)
|
||||||
|
setComparison(compareVariants(metricsA, metricsB))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(variantA).some((value) => value !== 0) ||
|
||||||
|
Object.values(variantB).some((value) => value !== 0),
|
||||||
|
[variantA, variantB],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<Heading size="sm" mb={4}>
|
||||||
|
A/B тест: сравнение вариантов
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4} mb={4}>
|
||||||
|
<GridItem>
|
||||||
|
<Heading size="xs" mb={2}>
|
||||||
|
Вариант A
|
||||||
|
</Heading>
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
<MetricInput
|
||||||
|
label="Submission Rate (%)"
|
||||||
|
value={variantA.submissionRate}
|
||||||
|
onChange={(value) => setVariantA((prev) => ({ ...prev, submissionRate: value }))}
|
||||||
|
suffix="Процент пользователей, отправивших хотя бы одно решение"
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Completion Rate (%)"
|
||||||
|
value={variantA.completionRate}
|
||||||
|
onChange={(value) => setVariantA((prev) => ({ ...prev, completionRate: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Retry Rate (%)"
|
||||||
|
value={variantA.retryRate}
|
||||||
|
onChange={(value) => setVariantA((prev) => ({ ...prev, retryRate: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Time to First Submission (мин)"
|
||||||
|
value={variantA.timeToFirstSubmission}
|
||||||
|
onChange={(value) => setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Session Duration (мин)"
|
||||||
|
value={variantA.sessionDuration}
|
||||||
|
onChange={(value) => setVariantA((prev) => ({ ...prev, sessionDuration: value }))}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</GridItem>
|
||||||
|
|
||||||
|
<GridItem>
|
||||||
|
<Heading size="xs" mb={2}>
|
||||||
|
Вариант B
|
||||||
|
</Heading>
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
<MetricInput
|
||||||
|
label="Submission Rate (%)"
|
||||||
|
value={variantB.submissionRate}
|
||||||
|
onChange={(value) => setVariantB((prev) => ({ ...prev, submissionRate: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Completion Rate (%)"
|
||||||
|
value={variantB.completionRate}
|
||||||
|
onChange={(value) => setVariantB((prev) => ({ ...prev, completionRate: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Retry Rate (%)"
|
||||||
|
value={variantB.retryRate}
|
||||||
|
onChange={(value) => setVariantB((prev) => ({ ...prev, retryRate: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Time to First Submission (мин)"
|
||||||
|
value={variantB.timeToFirstSubmission}
|
||||||
|
onChange={(value) => setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
||||||
|
/>
|
||||||
|
<MetricInput
|
||||||
|
label="Session Duration (мин)"
|
||||||
|
value={variantB.sessionDuration}
|
||||||
|
onChange={(value) => setVariantB((prev) => ({ ...prev, sessionDuration: value }))}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button onClick={handleCompare} colorScheme="teal" isDisabled={!hasData}>
|
||||||
|
Сравнить варианты
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{comparison && (
|
||||||
|
<Box mt={4} borderWidth="1px" borderRadius="md" borderColor="teal.200" bg="teal.50" p={4}>
|
||||||
|
<Heading size="xs" mb={2}>
|
||||||
|
Результат сравнения
|
||||||
|
</Heading>
|
||||||
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>Δ Submission Rate</StatLabel>
|
||||||
|
<StatValueText>{comparison.submissionRateDiff.toFixed(1)}%</StatValueText>
|
||||||
|
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>Δ Completion Rate</StatLabel>
|
||||||
|
<StatValueText>{comparison.completionRateDiff.toFixed(1)}%</StatValueText>
|
||||||
|
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Stat mt={4}>
|
||||||
|
<StatLabel>Победитель</StatLabel>
|
||||||
|
<StatValueText>Вариант {comparison.winner}</StatValueText>
|
||||||
|
<StatHelpText>Основано на сравнении коэффициента завершения</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
54
src/components/personal/ActivityHeatmap.tsx
Normal file
54
src/components/personal/ActivityHeatmap.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, SimpleGrid, Text, Tooltip } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { HeatmapData } from '../../__data__/types'
|
||||||
|
|
||||||
|
interface ActivityHeatmapProps {
|
||||||
|
data: HeatmapData
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellColor = (successRate: number) => {
|
||||||
|
if (successRate >= 70) return 'green.400'
|
||||||
|
if (successRate >= 40) return 'yellow.400'
|
||||||
|
if (successRate > 0) return 'orange.400'
|
||||||
|
return 'gray.300'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
|
||||||
|
if (!data.dates.length) {
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Text color="gray.500">Нет активности по датам</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Text fontWeight="semibold" mb={4}>
|
||||||
|
Активность по дням
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleGrid columns={7} spacing={2}>
|
||||||
|
{data.dates.map((day) => (
|
||||||
|
<Tooltip
|
||||||
|
key={day.date}
|
||||||
|
label={`${day.date}: ${day.submissions} попыток, ${day.successRate.toFixed(0)}% успех`}
|
||||||
|
hasArrow
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width="32px"
|
||||||
|
height="32px"
|
||||||
|
borderRadius="md"
|
||||||
|
bg={getCellColor(day.successRate)}
|
||||||
|
opacity={Math.min(day.submissions / 10, 1)}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="blackAlpha.100"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
56
src/components/personal/CheckStatusView.tsx
Normal file
56
src/components/personal/CheckStatusView.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Spinner, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { QueueStatus } from '../../__data__/types'
|
||||||
|
|
||||||
|
interface CheckStatusViewProps {
|
||||||
|
status: QueueStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckStatusView = ({ status }: CheckStatusViewProps) => {
|
||||||
|
if (status.status === 'waiting') {
|
||||||
|
return (
|
||||||
|
<Box textAlign="center" py={10} borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white">
|
||||||
|
<Spinner size="lg" mb={4} />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
Ожидание в очереди
|
||||||
|
</Text>
|
||||||
|
{typeof status.position === 'number' && (
|
||||||
|
<Text mt={2} color="gray.500">
|
||||||
|
Позиция в очереди: {status.position}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'in_progress') {
|
||||||
|
return (
|
||||||
|
<Box textAlign="center" py={10} borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white">
|
||||||
|
<Spinner size="lg" mb={4} />
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
Проверяем ваше решение...
|
||||||
|
</Text>
|
||||||
|
<Text mt={2} color="gray.500">
|
||||||
|
Это может занять несколько секунд
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Box textAlign="center" py={10} borderWidth="1px" borderRadius="lg" borderColor="red.200" bg="red.50">
|
||||||
|
<Text fontSize="lg" fontWeight="semibold" color="red.600">
|
||||||
|
Ошибка проверки
|
||||||
|
</Text>
|
||||||
|
<Text mt={2} color="red.500">
|
||||||
|
{status.error ?? 'Не удалось завершить проверку. Попробуйте позже.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
41
src/components/personal/MobileDashboard.tsx
Normal file
41
src/components/personal/MobileDashboard.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, HStack, Show, Stack, Stat, StatHelpText, StatLabel, StatValueText } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
|
||||||
|
export const MobileDashboard = () => {
|
||||||
|
const { personalDashboard } = useChallenge()
|
||||||
|
|
||||||
|
if (!personalDashboard) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show below="lg">
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4} mb={6}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>Сегодня выполнено</StatLabel>
|
||||||
|
<StatValueText>{personalDashboard.overview.tasksCompleted}</StatValueText>
|
||||||
|
<StatHelpText>Общий прогресс: {Math.round(personalDashboard.overview.completionPercentage)}%</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>Текущая цепочка</StatLabel>
|
||||||
|
<StatValueText>{personalDashboard.activeChains[0]?.name ?? '—'}</StatValueText>
|
||||||
|
<StatHelpText>
|
||||||
|
{personalDashboard.activeChains.length > 0
|
||||||
|
? `${Math.round(personalDashboard.activeChains[0].progress)}% завершено`
|
||||||
|
: 'Нет активных цепочек'}
|
||||||
|
</StatHelpText>
|
||||||
|
</Stat>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
193
src/components/personal/PersonalDashboard.tsx
Normal file
193
src/components/personal/PersonalDashboard.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Separator,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { useGetUserSubmissionsQuery } from '../../__data__/api/api'
|
||||||
|
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { ActivityHeatmap } from './ActivityHeatmap'
|
||||||
|
import { ProgressChart } from './ProgressChart'
|
||||||
|
import { StatCard } from './StatCard'
|
||||||
|
import { TimelineChart } from './TimelineChart'
|
||||||
|
import {
|
||||||
|
buildHeatmapData,
|
||||||
|
buildProgressChartData,
|
||||||
|
buildTimelineData,
|
||||||
|
downloadCSV,
|
||||||
|
exportUserProgress,
|
||||||
|
} from '../../utils/analytics'
|
||||||
|
|
||||||
|
interface PersonalDashboardProps {
|
||||||
|
onSelectTask: (task: ChallengeTask, chain: ChallengeChain) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
export const PersonalDashboard = ({ onSelectTask }: PersonalDashboardProps) => {
|
||||||
|
const { userId, stats, personalDashboard, chains } = useChallenge()
|
||||||
|
|
||||||
|
const { data: submissions = [], isFetching: isSubmissionsLoading } = useGetUserSubmissionsQuery(
|
||||||
|
{ userId: userId ?? '', taskId: undefined },
|
||||||
|
{ skip: !userId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const progressChartData = useMemo(() => (stats ? buildProgressChartData(stats) : null), [stats])
|
||||||
|
const timelineChartData = useMemo(() => buildTimelineData(submissions), [submissions])
|
||||||
|
const heatmapData = useMemo(() => buildHeatmapData(submissions), [submissions])
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!stats) return
|
||||||
|
const csv = await exportUserProgress(stats, submissions)
|
||||||
|
downloadCSV(csv, 'challenge-progress.csv')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats || !personalDashboard) {
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" p={6} bg="white">
|
||||||
|
<Text color="gray.600">Загрузка статистики...</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" spacing={6}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Heading size="md">Персональная статистика</Heading>
|
||||||
|
<Button onClick={handleExport} variant="outline" size="sm">
|
||||||
|
Экспортировать CSV
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<SimpleGrid minChildWidth="180px" spacing={4}>
|
||||||
|
<StatCard title="Заданий выполнено" value={personalDashboard.overview.tasksCompleted} icon="✅" />
|
||||||
|
<StatCard
|
||||||
|
title="Всего попыток"
|
||||||
|
value={personalDashboard.attemptsStats.totalAttempts}
|
||||||
|
icon="🧠"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Процент успеха"
|
||||||
|
value={`${formatNumber(personalDashboard.attemptsStats.successRate)}%`}
|
||||||
|
icon="📈"
|
||||||
|
/>
|
||||||
|
<StatCard title="Цепочек активно" value={personalDashboard.activeChains.length} icon="🧩" />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{progressChartData && <ProgressChart data={progressChartData} />}
|
||||||
|
|
||||||
|
<Stack direction={{ base: 'column', lg: 'row' }} spacing={4} align="stretch">
|
||||||
|
<Box flex="1">
|
||||||
|
<TimelineChart data={timelineChartData} />
|
||||||
|
</Box>
|
||||||
|
<Box flex="1">
|
||||||
|
<ActivityHeatmap data={heatmapData} />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Рекомендации
|
||||||
|
</Heading>
|
||||||
|
{personalDashboard.recommendations.length === 0 ? (
|
||||||
|
<Text color="gray.500">Новых рекомендаций пока нет.</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={2}>
|
||||||
|
{personalDashboard.recommendations.map((recommendation) => (
|
||||||
|
<Box
|
||||||
|
key={recommendation.actionLink}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="md"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={3}
|
||||||
|
>
|
||||||
|
<Text fontWeight="medium">{recommendation.message}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
Тип: {recommendation.type}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Heading size="sm" mb={4}>
|
||||||
|
Активные цепочки
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{personalDashboard.activeChains.length === 0 ? (
|
||||||
|
<Text color="gray.500">Начните новую цепочку, чтобы увидеть прогресс.</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={4}>
|
||||||
|
{personalDashboard.activeChains.map((chainStat) => {
|
||||||
|
const chain = chains.find((item) => item.id === chainStat.chainId) ?? null
|
||||||
|
const nextTask = chainStat.nextTask ?? chain?.tasks[chainStat.completedTasks] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={chainStat.chainId} borderWidth="1px" borderRadius="lg" borderColor="gray.200" p={4} bg="white">
|
||||||
|
<Flex justify="space-between" align={{ base: 'flex-start', md: 'center' }} direction={{ base: 'column', md: 'row' }}>
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="semibold">{chainStat.name}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
{chainStat.completedTasks} / {chain ? chain.tasks.length : chainStat.completedTasks} выполнено · {formatNumber(chainStat.progress)}%
|
||||||
|
</Text>
|
||||||
|
{nextTask && (
|
||||||
|
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||||
|
Следующее задание: {nextTask.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{nextTask && chain && (
|
||||||
|
<Button mt={{ base: 3, md: 0 }} onClick={() => onSelectTask(nextTask, chain)}>
|
||||||
|
Перейти к заданию
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Последние достижения
|
||||||
|
</Heading>
|
||||||
|
{personalDashboard.recentAchievements.length === 0 ? (
|
||||||
|
<Text color="gray.500">Достижений пока нет. Продолжайте работать!</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{personalDashboard.recentAchievements.map((achievement) => (
|
||||||
|
<Box key={`${achievement.type}-${achievement.timestamp}`} borderWidth="1px" borderColor="gray.200" borderRadius="md" p={3} bg="white">
|
||||||
|
<Text fontWeight="medium">{achievement.taskTitle}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
{achievement.type} · {new Date(achievement.timestamp).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isSubmissionsLoading && (
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
Обновляем историю отправок...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
47
src/components/personal/ProgressChart.tsx
Normal file
47
src/components/personal/ProgressChart.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Flex, Progress, Text, VStack } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ProgressChartData } from '../../__data__/types'
|
||||||
|
|
||||||
|
interface ProgressChartProps {
|
||||||
|
data: ProgressChartData
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROGRESS_KEYS: Array<{ key: keyof ProgressChartData; label: string; color: string }> = [
|
||||||
|
{ key: 'completed', label: 'Завершено', color: 'green' },
|
||||||
|
{ key: 'inProgress', label: 'В процессе', color: 'blue' },
|
||||||
|
{ key: 'needsRevision', label: 'Требует доработки', color: 'orange' },
|
||||||
|
{ key: 'notStarted', label: 'Не начато', color: 'gray' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ProgressChart = ({ data }: ProgressChartProps) => {
|
||||||
|
const total = Object.values(data).reduce((sum, value) => sum + value, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Text fontWeight="semibold" mb={4}>
|
||||||
|
Прогресс по заданиям
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{PROGRESS_KEYS.map(({ key, label, color }) => {
|
||||||
|
const value = data[key]
|
||||||
|
const percent = total ? Math.round((value / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
<Flex justifyContent="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm">{label}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
{value} ({percent}%)
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress value={percent} size="sm" colorScheme={color} borderRadius="sm" />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
69
src/components/personal/ResultView.tsx
Normal file
69
src/components/personal/ResultView.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Button, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ChallengeSubmission } from '../../__data__/types'
|
||||||
|
|
||||||
|
interface ResultViewProps {
|
||||||
|
submission: ChallengeSubmission
|
||||||
|
onRetry?: () => void
|
||||||
|
onNext?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (submission: ChallengeSubmission) => {
|
||||||
|
if (!submission.checkedAt) return 'N/A'
|
||||||
|
const submitted = new Date(submission.submittedAt).getTime()
|
||||||
|
const checked = new Date(submission.checkedAt).getTime()
|
||||||
|
const diff = Math.max(checked - submitted, 0)
|
||||||
|
return `${Math.round(diff / 1000)} сек`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResultView = ({ submission, onRetry, onNext }: ResultViewProps) => {
|
||||||
|
const isAccepted = submission.status === 'accepted'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor={isAccepted ? 'green.200' : 'orange.200'}
|
||||||
|
bg={isAccepted ? 'green.50' : 'orange.50'}
|
||||||
|
p={6}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Text fontSize="3xl" mb={2}>
|
||||||
|
{isAccepted ? '✅' : '❌'}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xl" fontWeight="semibold">
|
||||||
|
{isAccepted ? 'Задание принято!' : 'Требуется доработка'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{submission.feedback && (
|
||||||
|
<Box mt={4} textAlign="left" bg="white" borderRadius="md" p={4} borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Комментарий:
|
||||||
|
</Text>
|
||||||
|
<Text whiteSpace="pre-wrap" color="gray.700">
|
||||||
|
{submission.feedback}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text mt={4} color="gray.600">
|
||||||
|
Попытка №{submission.attemptNumber}. Время проверки: {formatDuration(submission)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box mt={6} display="flex" justifyContent="center" gap={3}>
|
||||||
|
{!isAccepted && onRetry && (
|
||||||
|
<Button onClick={onRetry} colorScheme="orange">
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAccepted && onNext && (
|
||||||
|
<Button onClick={onNext} colorScheme="green">
|
||||||
|
Следующее задание
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
34
src/components/personal/StatCard.tsx
Normal file
34
src/components/personal/StatCard.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Flex, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { StatCardProps } from '../../__data__/types'
|
||||||
|
|
||||||
|
export const StatCard = ({ title, value, change, trend, icon }: StatCardProps) => {
|
||||||
|
const trendColor = trend === 'down' ? 'red.500' : 'green.500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Flex alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{icon && (
|
||||||
|
<Box fontSize="lg" aria-hidden>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Text fontSize="3xl" fontWeight="semibold" mt={2} lineHeight="shorter">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{change !== undefined && trend && (
|
||||||
|
<Text fontSize="sm" color={trendColor} mt={1}>
|
||||||
|
{trend === 'up' ? '↑' : '↓'} {Math.abs(change)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
84
src/components/personal/TaskWorkspace.tsx
Normal file
84
src/components/personal/TaskWorkspace.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ChallengeTask } from '../../__data__/types'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { useSubmission } from '../../hooks/useSubmission'
|
||||||
|
import { CheckStatusView } from './CheckStatusView'
|
||||||
|
import { ResultView } from './ResultView'
|
||||||
|
|
||||||
|
interface TaskWorkspaceProps {
|
||||||
|
task: ChallengeTask
|
||||||
|
onTaskComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||||
|
const { refreshStats } = useChallenge()
|
||||||
|
const { result, setResult, submit, reset, queueStatus, finalSubmission, isSubmitting } = useSubmission({
|
||||||
|
taskId: task.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const descriptionBg = 'gray.50'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finalSubmission) {
|
||||||
|
refreshStats()
|
||||||
|
if (finalSubmission.status === 'accepted' && onTaskComplete) {
|
||||||
|
onTaskComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [finalSubmission, onTaskComplete, refreshStats])
|
||||||
|
|
||||||
|
if (queueStatus) {
|
||||||
|
return <CheckStatusView status={queueStatus} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalSubmission) {
|
||||||
|
return (
|
||||||
|
<ResultView submission={finalSubmission} onRetry={reset} onNext={onTaskComplete} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" spacing={4}>
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" p={4} bg={descriptionBg}>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold" mb={2}>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
<Text whiteSpace="pre-line" color="gray.700">
|
||||||
|
{task.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Ваше решение
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
value={result}
|
||||||
|
onChange={(event) => setResult(event.target.value)}
|
||||||
|
placeholder="Напишите ваше решение..."
|
||||||
|
rows={14}
|
||||||
|
bg="white"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<HStack justify="flex-end" spacing={3}>
|
||||||
|
<Button onClick={reset} variant="ghost">
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} colorScheme="teal" isLoading={isSubmitting} isDisabled={!result.trim()}>
|
||||||
|
Отправить на проверку
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
55
src/components/personal/TimelineChart.tsx
Normal file
55
src/components/personal/TimelineChart.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Flex, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { TimelineChartData } from '../../__data__/types'
|
||||||
|
|
||||||
|
interface TimelineChartProps {
|
||||||
|
data: TimelineChartData
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (value: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
} catch (error) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimelineChart = ({ data }: TimelineChartProps) => {
|
||||||
|
if (!data.submissions.length) {
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Text color="gray.500">Нет данных о проверках</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCheckTime = Math.max(...data.submissions.map((submission) => submission.checkTime), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderWidth="1px" borderColor="gray.200" borderRadius="lg" p={4} bg="white" boxShadow="sm">
|
||||||
|
<Text fontWeight="semibold" mb={4}>
|
||||||
|
Время проверки
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{data.submissions.map((submission) => (
|
||||||
|
<Box key={`${submission.timestamp}-${submission.checkTime}`} mb={2}>
|
||||||
|
<Flex justifyContent="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
{formatTime(submission.timestamp)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm">{Math.round(submission.checkTime)} сек</Text>
|
||||||
|
</Flex>
|
||||||
|
<Box
|
||||||
|
height="6px"
|
||||||
|
borderRadius="full"
|
||||||
|
bg={submission.status === 'accepted' ? 'green.300' : 'orange.300'}
|
||||||
|
width={`${(submission.checkTime / maxCheckTime) * 100}%`}
|
||||||
|
transition="width 0.2s ease"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
10
src/components/personal/index.ts
Normal file
10
src/components/personal/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { StatCard } from './StatCard'
|
||||||
|
export { ProgressChart } from './ProgressChart'
|
||||||
|
export { TimelineChart } from './TimelineChart'
|
||||||
|
export { ActivityHeatmap } from './ActivityHeatmap'
|
||||||
|
export { CheckStatusView } from './CheckStatusView'
|
||||||
|
export { ResultView } from './ResultView'
|
||||||
|
export { TaskWorkspace } from './TaskWorkspace'
|
||||||
|
export { PersonalDashboard } from './PersonalDashboard'
|
||||||
|
export { MobileDashboard } from './MobileDashboard'
|
||||||
|
|
||||||
237
src/context/ChallengeContext.tsx
Normal file
237
src/context/ChallengeContext.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
useAuthUserMutation,
|
||||||
|
useGetChainsQuery,
|
||||||
|
useLazyGetUserStatsQuery,
|
||||||
|
} from '../__data__/api/api'
|
||||||
|
import type { ChallengeChain, PersonalDashboard, UserStats } from '../__data__/types'
|
||||||
|
import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../utils/analytics'
|
||||||
|
import { ChallengeEventEmitter } from '../utils/events'
|
||||||
|
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
||||||
|
import { PollingManager } from '../utils/polling'
|
||||||
|
|
||||||
|
const isBrowser = () => typeof window !== 'undefined'
|
||||||
|
|
||||||
|
class ChallengeCache {
|
||||||
|
private cache = new Map<string, { data: unknown; expires: number }>()
|
||||||
|
|
||||||
|
set(key: string, data: unknown, ttl = 60_000) {
|
||||||
|
this.cache.set(key, { data, expires: Date.now() + ttl })
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() > entry.expires) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(key?: string) {
|
||||||
|
if (key) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeContextValue {
|
||||||
|
userId: string | null
|
||||||
|
nickname: string | null
|
||||||
|
stats: UserStats | null
|
||||||
|
personalDashboard: PersonalDashboard | null
|
||||||
|
chains: ChallengeChain[]
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isAuthLoading: boolean
|
||||||
|
isStatsLoading: boolean
|
||||||
|
login: (nickname: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
refreshStats: () => Promise<void>
|
||||||
|
eventEmitter: ChallengeEventEmitter
|
||||||
|
pollingManager: PollingManager
|
||||||
|
metricsCollector: MetricsCollector
|
||||||
|
behaviorTracker: BehaviorTracker
|
||||||
|
saveDraft: (taskId: string, value: string) => void
|
||||||
|
loadDraft: (taskId: string) => string | null
|
||||||
|
clearDraft: (taskId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
const USER_ID_KEY = 'challengeUserId'
|
||||||
|
const USER_NICKNAME_KEY = 'challengeNickname'
|
||||||
|
|
||||||
|
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const cacheRef = useRef(new ChallengeCache())
|
||||||
|
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
||||||
|
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
||||||
|
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
||||||
|
const pollingManager = useMemo(() => new PollingManager(), [])
|
||||||
|
|
||||||
|
const [userId, setUserId] = useState<string | null>(() =>
|
||||||
|
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
||||||
|
)
|
||||||
|
const [nickname, setNickname] = useState<string | null>(() =>
|
||||||
|
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
||||||
|
)
|
||||||
|
const [stats, setStats] = useState<UserStats | null>(null)
|
||||||
|
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||||
|
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||||
|
const cached = cacheRef.current.get<ChallengeChain[]>('chains')
|
||||||
|
return cached ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const [authUser, { isLoading: isAuthLoading }] = useAuthUserMutation()
|
||||||
|
const [triggerStats, statsResult] = useLazyGetUserStatsQuery()
|
||||||
|
|
||||||
|
const { data: chainsData, isLoading: isChainsLoading } = useGetChainsQuery(undefined, {
|
||||||
|
skip: !userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chainsData) {
|
||||||
|
setChains(chainsData)
|
||||||
|
cacheRef.current.set('chains', chainsData, 5 * 60_000)
|
||||||
|
}
|
||||||
|
}, [chainsData])
|
||||||
|
|
||||||
|
const refreshStatsById = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const cachedStats = cacheRef.current.get<UserStats>(`stats_${id}`)
|
||||||
|
if (cachedStats) {
|
||||||
|
setStats(cachedStats)
|
||||||
|
const cachedChains = cacheRef.current.get<ChallengeChain[]>('chains')
|
||||||
|
if (cachedChains) {
|
||||||
|
setPersonalDashboard(buildPersonalDashboard(cachedStats, cachedChains))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await triggerStats(id, true)
|
||||||
|
if ('data' in result && result.data) {
|
||||||
|
cacheRef.current.set(`stats_${id}`, result.data, 60_000)
|
||||||
|
setStats(result.data)
|
||||||
|
|
||||||
|
const cachedChains = cacheRef.current.get<ChallengeChain[]>('chains') ?? chains
|
||||||
|
setPersonalDashboard(buildPersonalDashboard(result.data, cachedChains))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[chains, triggerStats],
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshStats = useCallback(async () => {
|
||||||
|
if (!userId) return
|
||||||
|
await refreshStatsById(userId)
|
||||||
|
}, [refreshStatsById, userId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) {
|
||||||
|
refreshStats()
|
||||||
|
} else {
|
||||||
|
setStats(null)
|
||||||
|
setPersonalDashboard(null)
|
||||||
|
}
|
||||||
|
}, [refreshStats, userId])
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (nicknameValue: string) => {
|
||||||
|
const response = await authUser({ nickname: nicknameValue }).unwrap()
|
||||||
|
setUserId(response.userId)
|
||||||
|
setNickname(nicknameValue)
|
||||||
|
|
||||||
|
if (isBrowser()) {
|
||||||
|
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
||||||
|
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRef.current.clear('chains')
|
||||||
|
await refreshStatsById(response.userId)
|
||||||
|
},
|
||||||
|
[authUser, refreshStatsById],
|
||||||
|
)
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUserId(null)
|
||||||
|
setNickname(null)
|
||||||
|
setStats(null)
|
||||||
|
setPersonalDashboard(null)
|
||||||
|
cacheRef.current.clear()
|
||||||
|
|
||||||
|
if (isBrowser()) {
|
||||||
|
window.localStorage.removeItem(USER_ID_KEY)
|
||||||
|
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
||||||
|
|
||||||
|
const value = useMemo<ChallengeContextValue>(
|
||||||
|
() => ({
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
stats,
|
||||||
|
personalDashboard,
|
||||||
|
chains,
|
||||||
|
isAuthenticated: Boolean(userId),
|
||||||
|
isAuthLoading,
|
||||||
|
isStatsLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshStats,
|
||||||
|
eventEmitter,
|
||||||
|
pollingManager,
|
||||||
|
metricsCollector,
|
||||||
|
behaviorTracker,
|
||||||
|
saveDraft,
|
||||||
|
loadDraft,
|
||||||
|
clearDraft,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
behaviorTracker,
|
||||||
|
chains,
|
||||||
|
clearDraft,
|
||||||
|
eventEmitter,
|
||||||
|
isAuthLoading,
|
||||||
|
isStatsLoading,
|
||||||
|
loadDraft,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
metricsCollector,
|
||||||
|
nickname,
|
||||||
|
personalDashboard,
|
||||||
|
pollingManager,
|
||||||
|
refreshStats,
|
||||||
|
saveDraft,
|
||||||
|
stats,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <ChallengeContext.Provider value={value}>{children}</ChallengeContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChallenge = () => {
|
||||||
|
const context = useContext(ChallengeContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChallenge must be used within ChallengeProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,7 +2,7 @@ import React, { Suspense } from 'react'
|
|||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { URLs } from './__data__/urls'
|
import { URLs } from './__data__/urls'
|
||||||
import { MainPage } from './pages'
|
import { AdminPage, MainPage } from './pages'
|
||||||
|
|
||||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||||
@ -19,6 +19,16 @@ export const Dashboard = () => {
|
|||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{URLs.admin.isOn && (
|
||||||
|
<Route
|
||||||
|
path={URLs.admin.url}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<AdminPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/hooks/useSubmission.ts
Normal file
156
src/hooks/useSubmission.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
useLazyCheckQueueStatusQuery,
|
||||||
|
useSubmitSolutionMutation,
|
||||||
|
} from '../__data__/api/api'
|
||||||
|
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
|
||||||
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
|
||||||
|
interface UseSubmissionArgs {
|
||||||
|
taskId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionResult {
|
||||||
|
result: string
|
||||||
|
setResult: (value: string) => void
|
||||||
|
submit: () => Promise<void>
|
||||||
|
reset: () => void
|
||||||
|
queueStatus: QueueStatus | null
|
||||||
|
finalSubmission: ChallengeSubmission | null
|
||||||
|
isSubmitting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult => {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
eventEmitter,
|
||||||
|
pollingManager,
|
||||||
|
behaviorTracker,
|
||||||
|
metricsCollector,
|
||||||
|
saveDraft,
|
||||||
|
loadDraft,
|
||||||
|
clearDraft,
|
||||||
|
} = useChallenge()
|
||||||
|
|
||||||
|
const [result, setResultState] = useState('')
|
||||||
|
const [queueId, setQueueId] = useState<string | null>(null)
|
||||||
|
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null)
|
||||||
|
const [finalSubmission, setFinalSubmission] = useState<ChallengeSubmission | null>(null)
|
||||||
|
|
||||||
|
const [submitSolution, { isLoading: isSubmitting }] = useSubmitSolutionMutation()
|
||||||
|
const [triggerCheckStatus] = useLazyCheckQueueStatusQuery()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
behaviorTracker.reset()
|
||||||
|
const draft = loadDraft(taskId)
|
||||||
|
if (draft) {
|
||||||
|
setResultState(draft)
|
||||||
|
behaviorTracker.markDraftUsed()
|
||||||
|
} else {
|
||||||
|
setResultState('')
|
||||||
|
}
|
||||||
|
pollingManager.stop()
|
||||||
|
setQueueId(null)
|
||||||
|
setQueueStatus(null)
|
||||||
|
setFinalSubmission(null)
|
||||||
|
}, [behaviorTracker, loadDraft, pollingManager, taskId])
|
||||||
|
|
||||||
|
const setResult = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setResultState(value)
|
||||||
|
behaviorTracker.onTextChange(value)
|
||||||
|
saveDraft(taskId, value)
|
||||||
|
},
|
||||||
|
[behaviorTracker, saveDraft, taskId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setResultState('')
|
||||||
|
setQueueId(null)
|
||||||
|
setQueueStatus(null)
|
||||||
|
setFinalSubmission(null)
|
||||||
|
behaviorTracker.reset()
|
||||||
|
pollingManager.stop()
|
||||||
|
clearDraft(taskId)
|
||||||
|
}, [behaviorTracker, clearDraft, pollingManager, taskId])
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Пользователь не авторизован')
|
||||||
|
}
|
||||||
|
if (!result.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pollingManager.stop()
|
||||||
|
setFinalSubmission(null)
|
||||||
|
setQueueStatus(null)
|
||||||
|
|
||||||
|
const { queueId: newQueueId } = await submitSolution({ userId, taskId, result }).unwrap()
|
||||||
|
setQueueId(newQueueId)
|
||||||
|
metricsCollector.startTracking(queueStatus?.position ?? 0)
|
||||||
|
|
||||||
|
pollingManager.start(async () => {
|
||||||
|
const status = await triggerCheckStatus(newQueueId, true).unwrap()
|
||||||
|
metricsCollector.incrementPoll()
|
||||||
|
setQueueStatus(status)
|
||||||
|
|
||||||
|
if (status.status === 'completed' && status.submission) {
|
||||||
|
const performanceMetrics = metricsCollector.getMetrics(status.submission)
|
||||||
|
const behaviorMetrics = behaviorTracker.getMetrics(result)
|
||||||
|
|
||||||
|
const eventPayload = {
|
||||||
|
submission: status.submission,
|
||||||
|
performanceMetrics,
|
||||||
|
behaviorMetrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
eventEmitter.emit({
|
||||||
|
type: 'submission_completed',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userId,
|
||||||
|
data: eventPayload,
|
||||||
|
})
|
||||||
|
|
||||||
|
setFinalSubmission(status.submission)
|
||||||
|
clearDraft(taskId)
|
||||||
|
pollingManager.stop()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'error') {
|
||||||
|
pollingManager.stop()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
behaviorTracker,
|
||||||
|
clearDraft,
|
||||||
|
eventEmitter,
|
||||||
|
metricsCollector,
|
||||||
|
pollingManager,
|
||||||
|
queueStatus?.position,
|
||||||
|
result,
|
||||||
|
submitSolution,
|
||||||
|
taskId,
|
||||||
|
triggerCheckStatus,
|
||||||
|
userId,
|
||||||
|
])
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
result,
|
||||||
|
setResult,
|
||||||
|
submit,
|
||||||
|
reset,
|
||||||
|
queueStatus,
|
||||||
|
finalSubmission,
|
||||||
|
isSubmitting,
|
||||||
|
}),
|
||||||
|
[finalSubmission, isSubmitting, queueStatus, reset, result, setResult, submit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,24 +1,65 @@
|
|||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import i18next from 'i18next'
|
||||||
|
import { i18nextReactInitConfig } from '@brojs/cli'
|
||||||
|
|
||||||
import App from './app'
|
import App from './app'
|
||||||
|
import { keycloak } from "./__data__/kc"
|
||||||
|
import { createStore } from "./__data__/store"
|
||||||
|
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
|
||||||
|
|
||||||
export default () => <App/>
|
i18next.t = i18next.t.bind(i18next)
|
||||||
|
const i18nextPromise = i18nextReactInitConfig(i18next)
|
||||||
|
|
||||||
|
export default (props) => <App {...props} />
|
||||||
|
|
||||||
let rootElement: ReactDOM.Root
|
let rootElement: ReactDOM.Root
|
||||||
|
|
||||||
export const mount = (Component, element = document.getElementById('app')) => {
|
export const mount = async (Component, element = document.getElementById('app')) => {
|
||||||
rootElement = ReactDOM.createRoot(element)
|
let user = null
|
||||||
rootElement.render(<Component/>)
|
try {
|
||||||
|
if (isAuthLoopBlocked()) {
|
||||||
|
await i18nextPromise
|
||||||
|
rootElement = ReactDOM.createRoot(element)
|
||||||
|
rootElement.render(<button onClick={() => keycloak.login()} style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'red',
|
||||||
|
margin: 'auto'
|
||||||
|
}}>Login</button>)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAuthAttempt()
|
||||||
|
await keycloak.init({
|
||||||
|
onLoad: 'login-required'
|
||||||
|
// onLoad: 'check-sso'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInfo = await keycloak.loadUserInfo()
|
||||||
|
|
||||||
|
if (userInfo && keycloak.tokenParsed) {
|
||||||
|
user = { ...userInfo, ...keycloak.tokenParsed }
|
||||||
|
} else {
|
||||||
|
console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthAttempts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize adapter:', error)
|
||||||
|
// keycloak.login()
|
||||||
|
}
|
||||||
|
const store = createStore({ user })
|
||||||
|
await i18nextPromise
|
||||||
|
|
||||||
|
rootElement = ReactDOM.createRoot(element)
|
||||||
|
rootElement.render(<Component store={store} />)
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
if(module.hot) {
|
if(module.hot) {
|
||||||
// @ts-ignore
|
|
||||||
module.hot.accept('./app', ()=> {
|
module.hot.accept('./app', ()=> {
|
||||||
rootElement.render(<Component/>)
|
rootElement.render(<Component store={store} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
252
src/pages/admin/AdminDashboard.tsx
Normal file
252
src/pages/admin/AdminDashboard.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Heading,
|
||||||
|
SimpleGrid,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumnHeader,
|
||||||
|
TableContainer,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
Select,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
useGetAllSubmissionsQuery,
|
||||||
|
useGetChainsQuery,
|
||||||
|
useGetSystemStatsQuery,
|
||||||
|
} from '../../__data__/api/api'
|
||||||
|
import type { ChallengeChain } from '../../__data__/types'
|
||||||
|
import { StatCard } from '../../components/personal'
|
||||||
|
import { ABTestPanel } from '../../components/admin/ABTestPanel'
|
||||||
|
import { mapTaskMetrics, detectIssues, msToMinutes } from '../../utils/analytics'
|
||||||
|
import { keycloak } from '../../__data__/kc'
|
||||||
|
|
||||||
|
const formatNumber = (value: number | undefined) => {
|
||||||
|
if (!value && value !== 0) return '—'
|
||||||
|
return Intl.NumberFormat('ru-RU').format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTeacherRole = () => {
|
||||||
|
try {
|
||||||
|
return keycloak?.hasResourceRole?.('teacher', 'journal') ?? false
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminDashboard = () => {
|
||||||
|
const isTeacher = hasTeacherRole()
|
||||||
|
const { data: systemStats, isLoading } = useGetSystemStatsQuery()
|
||||||
|
const { data: chains = [] } = useGetChainsQuery(undefined, { skip: !isTeacher })
|
||||||
|
const { data: submissions = [] } = useGetAllSubmissionsQuery(undefined, { skip: !isTeacher })
|
||||||
|
|
||||||
|
const issues = useMemo(() => (systemStats ? detectIssues(systemStats) : []), [systemStats])
|
||||||
|
const taskMetrics = useMemo(() => mapTaskMetrics(submissions), [submissions])
|
||||||
|
const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'medium' | 'hard'>('all')
|
||||||
|
|
||||||
|
const filteredTaskMetrics = useMemo(() => {
|
||||||
|
if (difficultyFilter === 'all') return taskMetrics
|
||||||
|
return taskMetrics.filter((metric) => metric.difficulty === difficultyFilter)
|
||||||
|
}, [difficultyFilter, taskMetrics])
|
||||||
|
|
||||||
|
if (!isTeacher) {
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={8} maxW="480px" textAlign="center">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
Требуется роль преподавателя
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
У вас нет доступа к панели администратора. Обратитесь к администратору Keycloak для назначения роли
|
||||||
|
<Badge ml={2} colorScheme="purple">
|
||||||
|
teacher
|
||||||
|
</Badge>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !systemStats) {
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||||||
|
<Text color="gray.500">Загружаем системные метрики...</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||||
|
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||||
|
<Heading size="lg">Панель преподавателя</Heading>
|
||||||
|
|
||||||
|
<SimpleGrid minChildWidth="200px" spacing={4}>
|
||||||
|
<StatCard title="Пользователей" value={formatNumber(systemStats.users)} icon="👥" />
|
||||||
|
<StatCard title="Заданий" value={formatNumber(systemStats.tasks)} icon="🧩" />
|
||||||
|
<StatCard title="Цепочек" value={formatNumber(systemStats.chains)} icon="🔗" />
|
||||||
|
<StatCard title="Всего проверок" value={formatNumber(systemStats.submissions.total)} icon="✅" />
|
||||||
|
<StatCard
|
||||||
|
title="В ожидании"
|
||||||
|
value={formatNumber(systemStats.queue.waiting)}
|
||||||
|
icon="⏳"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Среднее время проверки"
|
||||||
|
value={`${msToMinutes(systemStats.averageCheckTimeMs)} мин`}
|
||||||
|
icon="⏱️"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Статус очереди
|
||||||
|
</Heading>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
Всего в очереди
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{formatNumber(systemStats.queue.queueLength)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
В ожидании
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{formatNumber(systemStats.queue.waiting)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
В обработке
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold">
|
||||||
|
{formatNumber(systemStats.queue.currentlyProcessing)} / {formatNumber(systemStats.queue.maxConcurrency)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Проблемные области
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{issues.length === 0 ? (
|
||||||
|
<Text color="green.500">Критических проблем не обнаружено.</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<Box
|
||||||
|
key={`${issue.type}-${issue.message}`}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="md"
|
||||||
|
borderColor={issue.severity === 'high' ? 'red.200' : 'yellow.200'}
|
||||||
|
bg={issue.severity === 'high' ? 'red.50' : 'yellow.50'}
|
||||||
|
p={3}
|
||||||
|
>
|
||||||
|
<Text fontWeight="medium">{issue.message}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Сущность: {issue.affectedEntity} · Важность: {issue.severity}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<HStack justify="space-between" mb={4} align="center">
|
||||||
|
<Heading size="sm">Метрики по заданиям</Heading>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
width="200px"
|
||||||
|
value={difficultyFilter}
|
||||||
|
onChange={(event) => setDifficultyFilter(event.target.value as typeof difficultyFilter)}
|
||||||
|
>
|
||||||
|
<option value="all">Все сложности</option>
|
||||||
|
<option value="easy">Лёгкие</option>
|
||||||
|
<option value="medium">Средние</option>
|
||||||
|
<option value="hard">Сложные</option>
|
||||||
|
</Select>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{filteredTaskMetrics.length === 0 ? (
|
||||||
|
<Text color="gray.500">Недостаточно данных о проверках для построения аналитики.</Text>
|
||||||
|
) : (
|
||||||
|
<Box overflowX="auto">
|
||||||
|
<Table size="sm" variant="simple">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableColumnHeader>Задание</TableColumnHeader>
|
||||||
|
<TableColumnHeader textAlign="right">Попыток</TableColumnHeader>
|
||||||
|
<TableColumnHeader textAlign="right">Успешность</TableColumnHeader>
|
||||||
|
<TableColumnHeader textAlign="right">Сред. попыток</TableColumnHeader>
|
||||||
|
<TableColumnHeader textAlign="right">Сред. время (мин)</TableColumnHeader>
|
||||||
|
<TableColumnHeader>Сложность</TableColumnHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredTaskMetrics.map((metric) => (
|
||||||
|
<TableRow key={metric.taskId}>
|
||||||
|
<TableCell>
|
||||||
|
<Text fontWeight="medium">{metric.title}</Text>
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
ID: {metric.taskId}
|
||||||
|
</Text>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell textAlign="right">{formatNumber(metric.attemptsCount)}</TableCell>
|
||||||
|
<TableCell textAlign="right">{formatNumber(Math.round(metric.successRate))}%</TableCell>
|
||||||
|
<TableCell textAlign="right">{formatNumber(Math.round(metric.avgAttempts * 10) / 10)}</TableCell>
|
||||||
|
<TableCell textAlign="right">{formatNumber(Math.round(msToMinutes(metric.avgTimeToComplete * 1000)))}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge colorScheme={metric.difficulty === 'hard' ? 'red' : metric.difficulty === 'medium' ? 'yellow' : 'green'}>
|
||||||
|
{metric.difficulty}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||||
|
<Heading size="sm" mb={3}>
|
||||||
|
Цепочки заданий
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{chains.length === 0 ? (
|
||||||
|
<Text color="gray.500">Цепочки не найдены. Создайте первое задание, чтобы начать.</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{chains.map((chain: ChallengeChain) => (
|
||||||
|
<Box key={chain.id} borderWidth="1px" borderRadius="md" borderColor="gray.200" p={3}>
|
||||||
|
<Text fontWeight="medium">{chain.name}</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Заданий: {chain.tasks.length} · Последнее обновление:{' '}
|
||||||
|
{new Date(chain.updatedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ABTestPanel />
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/admin/index.ts
Normal file
4
src/pages/admin/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { AdminDashboard } from './AdminDashboard'
|
||||||
|
|
||||||
|
export default AdminDashboard
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
|
|
||||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||||
|
export const AdminPage = lazy(() => import(/* webpackChunkName: 'admin' */'./admin'))
|
||||||
@ -1,28 +1,158 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Grid, GridItem } from '@chakra-ui/react'
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertIndicator,
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
||||||
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
|
import { MobileDashboard, PersonalDashboard, TaskWorkspace } from '../../components/personal'
|
||||||
|
|
||||||
export const MainPage = () => {
|
export const MainPage = () => {
|
||||||
|
const { nickname, personalDashboard, chains, eventEmitter } = useChallenge()
|
||||||
|
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
||||||
|
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
||||||
|
const [isOffline, setIsOffline] = useState(() =>
|
||||||
|
typeof navigator !== 'undefined' ? !navigator.onLine : false,
|
||||||
|
)
|
||||||
|
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
|
||||||
|
const notificationTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const isTaskSelected = Boolean(selectedChain && selectedTask)
|
||||||
|
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (nickname) {
|
||||||
|
return `Привет, ${nickname}!`
|
||||||
|
}
|
||||||
|
return 'Challenge Platform'
|
||||||
|
}, [nickname])
|
||||||
|
|
||||||
|
const handleSelectTask = (task: ChallengeTask, chain: ChallengeChain) => {
|
||||||
|
setSelectedChain(chain)
|
||||||
|
setSelectedTask(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskComplete = () => {
|
||||||
|
if (!selectedChain) return
|
||||||
|
const currentIndex = selectedChain.tasks.findIndex((item) => item.id === selectedTask?.id)
|
||||||
|
const nextTask = currentIndex >= 0 ? selectedChain.tasks[currentIndex + 1] : null
|
||||||
|
|
||||||
|
if (nextTask) {
|
||||||
|
setSelectedTask(nextTask)
|
||||||
|
} else {
|
||||||
|
setSelectedChain(null)
|
||||||
|
setSelectedTask(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTask = useMemo(() => {
|
||||||
|
if (selectedTask) return selectedTask
|
||||||
|
if (selectedChain) return selectedChain.tasks[0]
|
||||||
|
if (chains.length) return chains[0].tasks[0]
|
||||||
|
return null
|
||||||
|
}, [chains, selectedChain, selectedTask])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
||||||
|
const submission = (event.data as any)?.submission
|
||||||
|
const accepted = submission?.status === 'accepted'
|
||||||
|
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
|
||||||
|
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
||||||
|
|
||||||
|
if (notificationTimeoutRef.current) {
|
||||||
|
window.clearTimeout(notificationTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotification({ status: accepted ? 'success' : 'warning', title, description })
|
||||||
|
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
if (notificationTimeoutRef.current) {
|
||||||
|
window.clearTimeout(notificationTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [eventEmitter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOffline(false)
|
||||||
|
const handleOffline = () => setIsOffline(true)
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid
|
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||||
h="100%"
|
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||||
bgColor="gray.300"
|
{notification && (
|
||||||
templateAreas={{
|
<Alert status={notification.status} borderRadius="md">
|
||||||
md: `"header header"
|
<AlertIndicator />
|
||||||
"aside main"
|
<Box ml={3}>
|
||||||
"footer footer"`,
|
<Text fontWeight="semibold">{notification.title}</Text>
|
||||||
sm: `"header"
|
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
||||||
"main"
|
</Box>
|
||||||
"aside"
|
</Alert>
|
||||||
"footer"`,
|
)}
|
||||||
}}
|
|
||||||
gridTemplateRows={{ sm: '1fr', md: '50px 1fr 30px' }}
|
{isOffline && (
|
||||||
gridTemplateColumns={{ sm: '1fr', md: '150px 1fr' }}
|
<Alert status="warning" borderRadius="md">
|
||||||
gap={4}
|
<AlertIndicator />
|
||||||
>
|
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
||||||
<GridItem bgColor="green.100" gridArea="header">header</GridItem>
|
</Alert>
|
||||||
<GridItem bgColor="green.300" gridArea="aside">aside</GridItem>
|
)}
|
||||||
<GridItem bgColor="green.600" gridArea="main" h="100vh">main</GridItem>
|
|
||||||
<GridItem bgColor="green.300" gridArea="footer">footer</GridItem>
|
<Box>
|
||||||
</Grid>
|
<Heading size="lg" mb={1}>
|
||||||
|
{pageTitle}
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">Следите за прогрессом и отправляйте решения в одном месте.</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MobileDashboard />
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing={6} alignItems="start">
|
||||||
|
<PersonalDashboard onSelectTask={handleSelectTask} />
|
||||||
|
|
||||||
|
<Box position="sticky" top={8} height="fit-content">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
Рабочее пространство
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{personalDashboard && isTaskSelected && selectedTask && selectedChain ? (
|
||||||
|
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
||||||
|
) : fallbackTask ? (
|
||||||
|
<TaskWorkspace task={fallbackTask} onTaskComplete={handleTaskComplete} />
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
bg="white"
|
||||||
|
height="260px"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<Text color="gray.500" textAlign="center" px={6}>
|
||||||
|
Нет доступных заданий. Попросите преподавателя открыть цепочку или попробуйте позже.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
362
src/utils/analytics/index.ts
Normal file
362
src/utils/analytics/index.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import type {
|
||||||
|
AdminDashboardIssue,
|
||||||
|
AdminDashboardTaskMetric,
|
||||||
|
BehaviorMetrics,
|
||||||
|
ChallengeChain,
|
||||||
|
ChallengeSubmission,
|
||||||
|
HeatmapData,
|
||||||
|
PerformanceMetrics,
|
||||||
|
PersonalDashboard,
|
||||||
|
PersonalDashboardChain,
|
||||||
|
PersonalDashboardRecommendation,
|
||||||
|
ProgressChartData,
|
||||||
|
SuccessMetrics,
|
||||||
|
SystemStats,
|
||||||
|
TimelineChartData,
|
||||||
|
UserStats,
|
||||||
|
ABTestMetrics,
|
||||||
|
} from '../../__data__/types'
|
||||||
|
|
||||||
|
const MS_IN_SECOND = 1000
|
||||||
|
const MINUTES_IN_MS = 60_000
|
||||||
|
|
||||||
|
export class MetricsCollector {
|
||||||
|
private startTime = 0
|
||||||
|
private pollCount = 0
|
||||||
|
private initialQueuePosition = 0
|
||||||
|
|
||||||
|
startTracking(initialQueuePosition = 0) {
|
||||||
|
this.startTime = Date.now()
|
||||||
|
this.pollCount = 0
|
||||||
|
this.initialQueuePosition = initialQueuePosition
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementPoll() {
|
||||||
|
this.pollCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(submission: ChallengeSubmission): PerformanceMetrics {
|
||||||
|
const submittedAt = new Date(submission.submittedAt).getTime()
|
||||||
|
const checkedAt = submission.checkedAt ? new Date(submission.checkedAt).getTime() : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeToFeedback: Date.now() - this.startTime,
|
||||||
|
queueWaitTime: checkedAt ? Math.max(checkedAt - submittedAt, 0) : 0,
|
||||||
|
checkTime: checkedAt ? Math.max(checkedAt - submittedAt, 0) : 0,
|
||||||
|
initialQueuePosition: this.initialQueuePosition,
|
||||||
|
pollsBeforeComplete: this.pollCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BehaviorTracker {
|
||||||
|
private taskStartTime = Date.now()
|
||||||
|
private editCount = 0
|
||||||
|
private lastValue = ''
|
||||||
|
private usedDraft = false
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.taskStartTime = Date.now()
|
||||||
|
this.editCount = 0
|
||||||
|
this.lastValue = ''
|
||||||
|
this.usedDraft = false
|
||||||
|
}
|
||||||
|
|
||||||
|
markDraftUsed() {
|
||||||
|
this.usedDraft = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onTextChange(newValue: string) {
|
||||||
|
if (newValue !== this.lastValue) {
|
||||||
|
this.editCount += 1
|
||||||
|
this.lastValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(result: string): BehaviorMetrics {
|
||||||
|
const timeSpent = Math.floor((Date.now() - this.taskStartTime) / MS_IN_SECOND)
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeSpentOnTask: timeSpent,
|
||||||
|
solutionLength: result.length,
|
||||||
|
editCount: this.editCount,
|
||||||
|
usedDraft: this.usedDraft,
|
||||||
|
timeToSubmit: timeSpent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateSuccessMetrics(stats: UserStats): SuccessMetrics {
|
||||||
|
const taskStats = stats.taskStats
|
||||||
|
const completedTasks = taskStats.filter((task) => task.status === 'completed')
|
||||||
|
const firstAttemptSuccess = completedTasks.filter((task) => task.totalAttempts === 1)
|
||||||
|
const totalAttempts = completedTasks.reduce((sum, task) => sum + task.totalAttempts, 0)
|
||||||
|
const firstAttemptSuccessRate = taskStats.length
|
||||||
|
? (firstAttemptSuccess.length / taskStats.length) * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstAttemptSuccessRate,
|
||||||
|
averageAttemptsToSuccess: completedTasks.length ? totalAttempts / completedTasks.length : 0,
|
||||||
|
chainCompletionRate: stats.chainStats.length
|
||||||
|
? (stats.chainStats.filter((chain) => chain.progress === 100).length / stats.chainStats.length) * 100
|
||||||
|
: 0,
|
||||||
|
timeToFirstSuccess: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRecommendations(stats: UserStats): PersonalDashboardRecommendation[] {
|
||||||
|
const recommendations: PersonalDashboardRecommendation[] = []
|
||||||
|
|
||||||
|
if (stats.needsRevisionTasks > 0) {
|
||||||
|
recommendations.push({
|
||||||
|
type: 'retry',
|
||||||
|
message: `У вас ${stats.needsRevisionTasks} заданий требуют доработки`,
|
||||||
|
actionLink: '/tasks?status=needs_revision',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const inProgressChains = stats.chainStats.filter((chain) => chain.progress > 0 && chain.progress < 100)
|
||||||
|
|
||||||
|
if (inProgressChains.length > 0) {
|
||||||
|
const chain = inProgressChains[0]
|
||||||
|
recommendations.push({
|
||||||
|
type: 'continue',
|
||||||
|
message: `Продолжите цепочку "${chain.chainName}"`,
|
||||||
|
actionLink: `/chain/${chain.chainId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPersonalDashboard(stats: UserStats, chains: ChallengeChain[]): PersonalDashboard {
|
||||||
|
const activeChains: PersonalDashboardChain[] = stats.chainStats
|
||||||
|
.filter((chain) => chain.progress > 0 && chain.progress < 100)
|
||||||
|
.map((chainStat) => {
|
||||||
|
const chain = chains.find((item) => item.id === chainStat.chainId)
|
||||||
|
const completedCount = chainStat.completedTasks
|
||||||
|
const nextTask = chain?.tasks[completedCount] ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
chainId: chainStat.chainId,
|
||||||
|
name: chainStat.chainName,
|
||||||
|
progress: chainStat.progress,
|
||||||
|
nextTask,
|
||||||
|
estimatedTimeToComplete: Math.max(chainStat.totalTasks - chainStat.completedTasks, 0) * 10,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: {
|
||||||
|
tasksCompleted: stats.completedTasks,
|
||||||
|
totalTasks: stats.totalTasksAttempted,
|
||||||
|
completionPercentage: stats.totalTasksAttempted
|
||||||
|
? (stats.completedTasks / stats.totalTasksAttempted) * 100
|
||||||
|
: 0,
|
||||||
|
currentStreak: 0,
|
||||||
|
},
|
||||||
|
activeChains,
|
||||||
|
recentAchievements: [],
|
||||||
|
attemptsStats: {
|
||||||
|
totalAttempts: stats.totalSubmissions,
|
||||||
|
successfulAttempts: stats.completedTasks,
|
||||||
|
successRate: stats.totalSubmissions ? (stats.completedTasks / stats.totalSubmissions) * 100 : 0,
|
||||||
|
},
|
||||||
|
recommendations: generateRecommendations(stats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeDifficulty(successRate: number, avgAttempts: number): 'easy' | 'medium' | 'hard' {
|
||||||
|
if (successRate > 70 && avgAttempts < 2) return 'easy'
|
||||||
|
if (successRate > 40 && avgAttempts < 3) return 'medium'
|
||||||
|
return 'hard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectIssues(stats: SystemStats): AdminDashboardIssue[] {
|
||||||
|
const issues: AdminDashboardIssue[] = []
|
||||||
|
|
||||||
|
if (stats.queue.queueLength > 50) {
|
||||||
|
issues.push({
|
||||||
|
type: 'long_queue',
|
||||||
|
severity: 'high',
|
||||||
|
message: `Очередь содержит ${stats.queue.queueLength} заданий`,
|
||||||
|
affectedEntity: 'system',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemSuccessRate = stats.submissions.total
|
||||||
|
? (stats.submissions.accepted / stats.submissions.total) * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (systemSuccessRate < 30) {
|
||||||
|
issues.push({
|
||||||
|
type: 'low_success_rate',
|
||||||
|
severity: 'medium',
|
||||||
|
message: `Общий процент принятых заданий всего ${systemSuccessRate.toFixed(1)}%`,
|
||||||
|
affectedEntity: 'system',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTaskMetrics(submissions: ChallengeSubmission[]): AdminDashboardTaskMetric[] {
|
||||||
|
const grouped = new Map<string, ChallengeSubmission[]>()
|
||||||
|
|
||||||
|
submissions.forEach((submission) => {
|
||||||
|
const key = typeof submission.task === 'string' ? submission.task : submission.task.id
|
||||||
|
const list = grouped.get(key) ?? []
|
||||||
|
list.push(submission)
|
||||||
|
grouped.set(key, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([taskId, taskSubmissions]) => {
|
||||||
|
const accepted = taskSubmissions.filter((item) => item.status === 'accepted')
|
||||||
|
const successRate = taskSubmissions.length
|
||||||
|
? (accepted.length / taskSubmissions.length) * 100
|
||||||
|
: 0
|
||||||
|
const attemptsByUser = new Map<string, number>()
|
||||||
|
|
||||||
|
taskSubmissions.forEach((submission) => {
|
||||||
|
const userId = typeof submission.user === 'string' ? submission.user : submission.user.id
|
||||||
|
const attempts = attemptsByUser.get(userId) ?? 0
|
||||||
|
attemptsByUser.set(userId, Math.max(attempts, submission.attemptNumber))
|
||||||
|
})
|
||||||
|
|
||||||
|
const avgAttempts = attemptsByUser.size
|
||||||
|
? Array.from(attemptsByUser.values()).reduce((a, b) => a + b, 0) / attemptsByUser.size
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const avgTimeToComplete = accepted.length
|
||||||
|
? accepted.reduce((sum, submission) => {
|
||||||
|
const submittedAt = new Date(submission.submittedAt).getTime()
|
||||||
|
const checkedAt = submission.checkedAt ? new Date(submission.checkedAt).getTime() : submittedAt
|
||||||
|
return sum + Math.max(checkedAt - submittedAt, 0)
|
||||||
|
}, 0) / accepted.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const sample = taskSubmissions[0]
|
||||||
|
const title = typeof sample.task === 'string' ? taskId : sample.task.title
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
title,
|
||||||
|
attemptsCount: taskSubmissions.length,
|
||||||
|
successRate,
|
||||||
|
avgAttempts,
|
||||||
|
avgTimeToComplete,
|
||||||
|
difficulty: analyzeDifficulty(successRate, avgAttempts),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportUserProgress(
|
||||||
|
stats: UserStats,
|
||||||
|
submissions: ChallengeSubmission[],
|
||||||
|
): Promise<string> {
|
||||||
|
const header = 'Task,Status,Attempts,Last Attempt,Feedback\n'
|
||||||
|
const rows = submissions.map((submission) => {
|
||||||
|
const taskTitle = typeof submission.task === 'string' ? submission.task : submission.task.title
|
||||||
|
const feedback = submission.feedback ? submission.feedback.replace(/"/g, '""') : ''
|
||||||
|
const lastAttempt = submission.checkedAt ?? submission.submittedAt
|
||||||
|
|
||||||
|
return `"${taskTitle}","${submission.status}",${submission.attemptNumber},"${lastAttempt}","${feedback}"
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0 && stats.taskStats.length > 0) {
|
||||||
|
stats.taskStats.forEach((task) => {
|
||||||
|
rows.push(
|
||||||
|
`"${task.taskTitle}","${task.status}",${task.totalAttempts},"${task.lastAttemptAt ?? 'N/A'}",""\n`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return header + rows.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadCSV(csv: string, filename: string) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareVariants(variantA: ABTestMetrics, variantB: ABTestMetrics) {
|
||||||
|
const submissionRateDiff = variantA.submissionRate
|
||||||
|
? ((variantB.submissionRate - variantA.submissionRate) / variantA.submissionRate) * 100
|
||||||
|
: 0
|
||||||
|
const completionRateDiff = variantA.completionRate
|
||||||
|
? ((variantB.completionRate - variantA.completionRate) / variantA.completionRate) * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
submissionRateDiff,
|
||||||
|
completionRateDiff,
|
||||||
|
winner: variantB.completionRate > variantA.completionRate ? 'B' : 'A',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProgressChartData(stats: UserStats): ProgressChartData {
|
||||||
|
const completed = stats.taskStats.filter((task) => task.status === 'completed').length
|
||||||
|
const needsRevision = stats.taskStats.filter((task) => task.status === 'needs_revision').length
|
||||||
|
const inProgress = stats.taskStats.filter((task) => task.status === 'in_progress').length
|
||||||
|
const pending = stats.taskStats.filter((task) => task.status === 'pending').length
|
||||||
|
const notStarted = stats.taskStats.filter((task) => task.status === 'not_attempted').length
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
inProgress: inProgress + pending,
|
||||||
|
needsRevision,
|
||||||
|
notStarted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineData(submissions: ChallengeSubmission[]): TimelineChartData {
|
||||||
|
return {
|
||||||
|
submissions: submissions
|
||||||
|
.filter((submission) => submission.checkedAt)
|
||||||
|
.map((submission) => ({
|
||||||
|
timestamp: submission.checkedAt as string,
|
||||||
|
checkTime: Math.max(
|
||||||
|
(new Date(submission.checkedAt as string).getTime() - new Date(submission.submittedAt).getTime()) /
|
||||||
|
MS_IN_SECOND,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
status: submission.status === 'accepted' ? 'accepted' : 'needs_revision',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHeatmapData(submissions: ChallengeSubmission[]): HeatmapData {
|
||||||
|
const grouped = new Map<string, { submissions: number; accepted: number }>()
|
||||||
|
|
||||||
|
submissions.forEach((submission) => {
|
||||||
|
const date = new Date(submission.submittedAt).toISOString().split('T')[0]
|
||||||
|
const record = grouped.get(date) ?? { submissions: 0, accepted: 0 }
|
||||||
|
record.submissions += 1
|
||||||
|
if (submission.status === 'accepted') {
|
||||||
|
record.accepted += 1
|
||||||
|
}
|
||||||
|
grouped.set(date, record)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
dates: Array.from(grouped.entries()).map(([date, value]) => ({
|
||||||
|
date,
|
||||||
|
submissions: value.submissions,
|
||||||
|
successRate: value.submissions ? (value.accepted / value.submissions) * 100 : 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToMinutes(ms: number) {
|
||||||
|
return Math.round(ms / MINUTES_IN_MS)
|
||||||
|
}
|
||||||
|
|
||||||
59
src/utils/authLoopGuard.ts
Normal file
59
src/utils/authLoopGuard.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const STORAGE_KEY = 'auth.loop.attempts'
|
||||||
|
const DEFAULT_WINDOW_MS = 2_000
|
||||||
|
const DEFAULT_THRESHOLD = 2
|
||||||
|
|
||||||
|
const readAttempts = (): number[] => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter((n) => typeof n === 'number')
|
||||||
|
return []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeAttempts = (attempts: number[]) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recordAuthAttempt = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const attempts = readAttempts()
|
||||||
|
const updated = [...attempts, now].slice(-10)
|
||||||
|
writeAttempts(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAuthAttempts = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRecentAttempts = (windowMs: number = DEFAULT_WINDOW_MS): number[] => {
|
||||||
|
const now = Date.now()
|
||||||
|
return readAttempts().filter((ts) => now - ts <= windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthLoopBlocked = (
|
||||||
|
windowMs: number = DEFAULT_WINDOW_MS,
|
||||||
|
threshold: number = DEFAULT_THRESHOLD,
|
||||||
|
): boolean => {
|
||||||
|
return getRecentAttempts(windowMs).length >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_LOOP_GUARD = {
|
||||||
|
recordAuthAttempt,
|
||||||
|
clearAuthAttempts,
|
||||||
|
getRecentAttempts,
|
||||||
|
isAuthLoopBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
32
src/utils/drafts.ts
Normal file
32
src/utils/drafts.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const STORAGE_PREFIX = 'challenge_draft_'
|
||||||
|
|
||||||
|
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||||
|
|
||||||
|
export function saveDraft(taskId: string, result: string) {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
window.localStorage.setItem(`${STORAGE_PREFIX}${taskId}`, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDraft(taskId: string): string | null {
|
||||||
|
if (!isBrowser()) return null
|
||||||
|
return window.localStorage.getItem(`${STORAGE_PREFIX}${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(taskId: string) {
|
||||||
|
if (!isBrowser()) return
|
||||||
|
window.localStorage.removeItem(`${STORAGE_PREFIX}${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDrafts() {
|
||||||
|
if (!isBrowser()) return [] as string[]
|
||||||
|
|
||||||
|
const keys = [] as string[]
|
||||||
|
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||||
|
const key = window.localStorage.key(i)
|
||||||
|
if (key?.startsWith(STORAGE_PREFIX)) {
|
||||||
|
keys.push(key.replace(STORAGE_PREFIX, ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
28
src/utils/errors.ts
Normal file
28
src/utils/errors.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export class ChallengeAPIError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public statusCode?: number,
|
||||||
|
public details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ChallengeAPIError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAPIError(response: Response) {
|
||||||
|
let data: any = null
|
||||||
|
try {
|
||||||
|
data = await response.clone().json()
|
||||||
|
} catch (error) {
|
||||||
|
// ignore json parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || data?.error) {
|
||||||
|
const errorPayload = data?.error ?? data
|
||||||
|
const message = errorPayload?.message || response.statusText || 'Unknown error'
|
||||||
|
throw new ChallengeAPIError(message, response.status, errorPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
50
src/utils/events.ts
Normal file
50
src/utils/events.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type { ChallengeEvent } from '../__data__/types'
|
||||||
|
|
||||||
|
type EventCallback<T = unknown> = (event: ChallengeEvent<T>) => void
|
||||||
|
|
||||||
|
export class ChallengeEventEmitter {
|
||||||
|
private listeners = new Map<string, Set<EventCallback>>()
|
||||||
|
|
||||||
|
on<T = unknown>(type: string, callback: EventCallback<T>) {
|
||||||
|
const current = this.listeners.get(type) ?? new Set<EventCallback>()
|
||||||
|
current.add(callback as EventCallback)
|
||||||
|
this.listeners.set(type, current)
|
||||||
|
|
||||||
|
return () => this.off(type, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
off<T = unknown>(type: string, callback: EventCallback<T>) {
|
||||||
|
const current = this.listeners.get(type)
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.delete(callback as EventCallback)
|
||||||
|
if (current.size === 0) {
|
||||||
|
this.listeners.delete(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<T = unknown>(event: ChallengeEvent<T>) {
|
||||||
|
const current = this.listeners.get(event.type)
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(event as ChallengeEvent)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ChallengeEventEmitter listener error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(type?: string) {
|
||||||
|
if (type) {
|
||||||
|
this.listeners.delete(type)
|
||||||
|
} else {
|
||||||
|
this.listeners.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
71
src/utils/polling.ts
Normal file
71
src/utils/polling.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
type PollCallback = () => Promise<boolean> | boolean
|
||||||
|
|
||||||
|
export interface PollingOptions {
|
||||||
|
initialDelay?: number
|
||||||
|
maxDelay?: number
|
||||||
|
multiplier?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PollingManager {
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private currentDelay: number
|
||||||
|
private readonly maxDelay: number
|
||||||
|
private readonly multiplier: number
|
||||||
|
private running = false
|
||||||
|
|
||||||
|
constructor(options: PollingOptions = {}) {
|
||||||
|
this.currentDelay = options.initialDelay ?? 2000
|
||||||
|
this.maxDelay = options.maxDelay ?? 10000
|
||||||
|
this.multiplier = options.multiplier ?? 1.5
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(callback: PollCallback) {
|
||||||
|
if (this.running) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true
|
||||||
|
await this.poll(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId)
|
||||||
|
this.timeoutId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentDelay = Math.min(this.currentDelay, this.maxDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(callback: PollCallback) {
|
||||||
|
const delay = Math.min(this.currentDelay, this.maxDelay)
|
||||||
|
|
||||||
|
this.timeoutId = setTimeout(async () => {
|
||||||
|
await this.poll(callback)
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
this.currentDelay = Math.min(this.currentDelay * this.multiplier, this.maxDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(callback: PollCallback) {
|
||||||
|
if (!this.running) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shouldContinue = await callback()
|
||||||
|
|
||||||
|
if (shouldContinue) {
|
||||||
|
this.schedule(callback)
|
||||||
|
} else {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error)
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
4
stubs/api/data/auth.json
Normal file
4
stubs/api/data/auth.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"userId": "user-frontend-001"
|
||||||
|
}
|
||||||
27
stubs/api/data/chains.json
Normal file
27
stubs/api/data/chains.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "chain-frontend",
|
||||||
|
"_id": "chain-frontend",
|
||||||
|
"name": "Frontend Basics",
|
||||||
|
"createdAt": "2024-09-01T08:00:00.000Z",
|
||||||
|
"updatedAt": "2024-10-12T10:15:00.000Z",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "task-html-intro",
|
||||||
|
"_id": "task-html-intro",
|
||||||
|
"title": "HTML старт",
|
||||||
|
"description": "# HTML старт\n\nСоздайте базовую HTML-страницу с заголовком и абзацем.",
|
||||||
|
"createdAt": "2024-09-01T08:05:00.000Z",
|
||||||
|
"updatedAt": "2024-09-10T12:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-react-component",
|
||||||
|
"_id": "task-react-component",
|
||||||
|
"title": "React компонент",
|
||||||
|
"description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.",
|
||||||
|
"createdAt": "2024-09-05T11:30:00.000Z",
|
||||||
|
"updatedAt": "2024-10-01T09:45:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
57
stubs/api/data/queue-status.json
Normal file
57
stubs/api/data/queue-status.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"queue-frontend-001": {
|
||||||
|
"status": "completed",
|
||||||
|
"position": 0,
|
||||||
|
"submission": {
|
||||||
|
"_id": "submission-001",
|
||||||
|
"id": "submission-001",
|
||||||
|
"user": "user-frontend-001",
|
||||||
|
"task": "task-html-intro",
|
||||||
|
"result": "<html><head></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "queue-frontend-001",
|
||||||
|
"feedback": "Добавьте тег <title>",
|
||||||
|
"submittedAt": "2024-10-18T07:10:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:10:15.000Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queue-frontend-002": {
|
||||||
|
"status": "completed",
|
||||||
|
"position": 0,
|
||||||
|
"submission": {
|
||||||
|
"_id": "submission-002",
|
||||||
|
"id": "submission-002",
|
||||||
|
"user": "user-frontend-001",
|
||||||
|
"task": "task-html-intro",
|
||||||
|
"result": "<html><head><title>Home</title></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "queue-frontend-002",
|
||||||
|
"feedback": "Отличная работа!",
|
||||||
|
"submittedAt": "2024-10-18T07:20:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:20:05.000Z",
|
||||||
|
"attemptNumber": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queue-frontend-003": {
|
||||||
|
"status": "waiting",
|
||||||
|
"position": 3
|
||||||
|
},
|
||||||
|
"queue-react-001": {
|
||||||
|
"status": "completed",
|
||||||
|
"position": 0,
|
||||||
|
"submission": {
|
||||||
|
"_id": "submission-004",
|
||||||
|
"id": "submission-004",
|
||||||
|
"user": "user-react-777",
|
||||||
|
"task": "task-react-component",
|
||||||
|
"result": "export const StatCard = () => <div>Stat</div>;",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "queue-react-001",
|
||||||
|
"feedback": "Добавьте prop-types.",
|
||||||
|
"submittedAt": "2024-10-17T11:30:00.000Z",
|
||||||
|
"checkedAt": "2024-10-17T11:30:07.000Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
stubs/api/data/submissions.json
Normal file
59
stubs/api/data/submissions.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "submission-001",
|
||||||
|
"id": "submission-001",
|
||||||
|
"user": {
|
||||||
|
"id": "user-frontend-001",
|
||||||
|
"nickname": "frontend_ninja"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"id": "task-html-intro",
|
||||||
|
"title": "HTML старт"
|
||||||
|
},
|
||||||
|
"result": "<html><head></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "queue-frontend-001",
|
||||||
|
"feedback": "Добавьте тег <title>",
|
||||||
|
"submittedAt": "2024-10-18T07:10:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:10:15.000Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "submission-002",
|
||||||
|
"id": "submission-002",
|
||||||
|
"user": {
|
||||||
|
"id": "user-frontend-001",
|
||||||
|
"nickname": "frontend_ninja"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"id": "task-html-intro",
|
||||||
|
"title": "HTML старт"
|
||||||
|
},
|
||||||
|
"result": "<html><head><title>Home</title></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "queue-frontend-002",
|
||||||
|
"feedback": "Отличная работа!",
|
||||||
|
"submittedAt": "2024-10-18T07:20:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:20:05.000Z",
|
||||||
|
"attemptNumber": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "submission-004",
|
||||||
|
"id": "submission-004",
|
||||||
|
"user": {
|
||||||
|
"id": "user-react-777",
|
||||||
|
"nickname": "react_master"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"id": "task-react-component",
|
||||||
|
"title": "React компонент"
|
||||||
|
},
|
||||||
|
"result": "export const StatCard = () => <div>Stat</div>;",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "queue-react-001",
|
||||||
|
"feedback": "Добавьте prop-types.",
|
||||||
|
"submittedAt": "2024-10-17T11:30:00.000Z",
|
||||||
|
"checkedAt": "2024-10-17T11:30:07.000Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
4
stubs/api/data/submit.json
Normal file
4
stubs/api/data/submit.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"queueId": "queue-frontend-003",
|
||||||
|
"submissionId": "submission-003"
|
||||||
|
}
|
||||||
20
stubs/api/data/system-stats.json
Normal file
20
stubs/api/data/system-stats.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"users": 128,
|
||||||
|
"tasks": 34,
|
||||||
|
"chains": 5,
|
||||||
|
"submissions": {
|
||||||
|
"total": 540,
|
||||||
|
"accepted": 312,
|
||||||
|
"rejected": 144,
|
||||||
|
"pending": 62,
|
||||||
|
"inProgress": 22
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 5600,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 18,
|
||||||
|
"waiting": 12,
|
||||||
|
"inProgress": 6,
|
||||||
|
"maxConcurrency": 8,
|
||||||
|
"currentlyProcessing": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
59
stubs/api/data/user-stats.json
Normal file
59
stubs/api/data/user-stats.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"user-frontend-001": {
|
||||||
|
"totalTasksAttempted": 2,
|
||||||
|
"completedTasks": 1,
|
||||||
|
"inProgressTasks": 1,
|
||||||
|
"needsRevisionTasks": 0,
|
||||||
|
"totalSubmissions": 3,
|
||||||
|
"averageCheckTimeMs": 4200,
|
||||||
|
"taskStats": [
|
||||||
|
{
|
||||||
|
"taskId": "task-html-intro",
|
||||||
|
"taskTitle": "HTML старт",
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"attemptNumber": 1,
|
||||||
|
"status": "needs_revision",
|
||||||
|
"submittedAt": "2024-10-18T07:10:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:10:15.000Z",
|
||||||
|
"feedback": "Добавьте тег <title>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attemptNumber": 2,
|
||||||
|
"status": "accepted",
|
||||||
|
"submittedAt": "2024-10-18T07:20:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:20:05.000Z",
|
||||||
|
"feedback": "Отличная работа!"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalAttempts": 2,
|
||||||
|
"status": "completed",
|
||||||
|
"lastAttemptAt": "2024-10-18T07:20:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task-react-component",
|
||||||
|
"taskTitle": "React компонент",
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"attemptNumber": 1,
|
||||||
|
"status": "pending",
|
||||||
|
"submittedAt": "2024-10-19T09:05:00.000Z",
|
||||||
|
"feedback": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalAttempts": 1,
|
||||||
|
"status": "pending",
|
||||||
|
"lastAttemptAt": "2024-10-19T09:05:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainStats": [
|
||||||
|
{
|
||||||
|
"chainId": "chain-frontend",
|
||||||
|
"chainName": "Frontend Basics",
|
||||||
|
"totalTasks": 2,
|
||||||
|
"completedTasks": 1,
|
||||||
|
"progress": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
43
stubs/api/data/user-submissions.json
Normal file
43
stubs/api/data/user-submissions.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"user-frontend-001": [
|
||||||
|
{
|
||||||
|
"_id": "submission-001",
|
||||||
|
"id": "submission-001",
|
||||||
|
"user": "user-frontend-001",
|
||||||
|
"task": "task-html-intro",
|
||||||
|
"result": "<html><head></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "queue-frontend-001",
|
||||||
|
"feedback": "Добавьте тег <title>",
|
||||||
|
"submittedAt": "2024-10-18T07:10:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:10:15.000Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "submission-002",
|
||||||
|
"id": "submission-002",
|
||||||
|
"user": "user-frontend-001",
|
||||||
|
"task": "task-html-intro",
|
||||||
|
"result": "<html><head><title>Home</title></head><body><h1>Hello</h1></body></html>",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "queue-frontend-002",
|
||||||
|
"feedback": "Отличная работа!",
|
||||||
|
"submittedAt": "2024-10-18T07:20:00.000Z",
|
||||||
|
"checkedAt": "2024-10-18T07:20:05.000Z",
|
||||||
|
"attemptNumber": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "submission-003",
|
||||||
|
"id": "submission-003",
|
||||||
|
"user": "user-frontend-001",
|
||||||
|
"task": "task-react-component",
|
||||||
|
"result": "export const StatCard = () => null;",
|
||||||
|
"status": "pending",
|
||||||
|
"queueId": "queue-frontend-003",
|
||||||
|
"feedback": null,
|
||||||
|
"submittedAt": "2024-10-19T09:05:00.000Z",
|
||||||
|
"checkedAt": null,
|
||||||
|
"attemptNumber": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,8 +1,96 @@
|
|||||||
const router = require('express').Router();
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const router = require('express').Router()
|
||||||
|
|
||||||
const timer = (time = 300) => (req, res, next) => setTimeout(next, time);
|
const timer = (time = 300) => (req, res, next) => setTimeout(next, time)
|
||||||
|
|
||||||
router.use(timer());
|
const dataDir = path.join(__dirname, 'data')
|
||||||
|
|
||||||
|
const readJson = (fileName) => {
|
||||||
|
const filePath = path.join(dataDir, fileName)
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
const sendNotFound = (res, message) => {
|
||||||
|
res.status(404).json({ error: { message }, data: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(timer())
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
res.type('application/json')
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/auth', (req, res) => {
|
||||||
|
res.json(readJson('auth.json'))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/chains', (req, res) => {
|
||||||
|
res.json(readJson('chains.json'))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/chain/:id', (req, res) => {
|
||||||
|
const chains = readJson('chains.json')
|
||||||
|
const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id)
|
||||||
|
if (!chain) {
|
||||||
|
return sendNotFound(res, `Цепочка ${req.params.id} не найдена`)
|
||||||
|
}
|
||||||
|
return res.json(chain)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/task/:id', (req, res) => {
|
||||||
|
const chains = readJson('chains.json')
|
||||||
|
const task = chains
|
||||||
|
.flatMap((chain) => chain.tasks || [])
|
||||||
|
.find((item) => item.id === req.params.id || item._id === req.params.id)
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return sendNotFound(res, `Задание ${req.params.id} не найдено`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(task)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/submit', (req, res) => {
|
||||||
|
const response = readJson('submit.json')
|
||||||
|
res.json(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/check-status/:queueId', (req, res) => {
|
||||||
|
const statuses = readJson('queue-status.json')
|
||||||
|
const status = statuses[req.params.queueId]
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return sendNotFound(res, `Статус очереди ${req.params.queueId} не найден`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/user/:userId/stats', (req, res) => {
|
||||||
|
const statsMap = readJson('user-stats.json')
|
||||||
|
const stats = statsMap[req.params.userId]
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return sendNotFound(res, `Статистика пользователя ${req.params.userId} не найдена`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/user/:userId/submissions', (req, res) => {
|
||||||
|
const submissionsMap = readJson('user-submissions.json')
|
||||||
|
const submissions = submissionsMap[req.params.userId] || []
|
||||||
|
return res.json(submissions)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/stats', (req, res) => {
|
||||||
|
res.json(readJson('system-stats.json'))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/submissions', (req, res) => {
|
||||||
|
res.json(readJson('submissions.json'))
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": ["dom", "es2017"],
|
||||||
"dom",
|
|
||||||
"es2017"
|
|
||||||
],
|
|
||||||
"outDir": "./dist/",
|
"outDir": "./dist/",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"typeRoots": ["node_modules/@types", "src/typings"],
|
"typeRoots": ["node_modules/@types", "./@types"],
|
||||||
"types" : ["webpack-env", "node"],
|
"types": ["webpack-env", "node"],
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
},
|
},
|
||||||
|
"types": [
|
||||||
|
"@types/*"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user