From 624280ab5e1c30f29d93bdb9b416c3b72e5dac13 Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Alexandrovich Date: Mon, 3 Nov 2025 12:55:34 +0300 Subject: [PATCH] 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. --- @types/index.d.ts | 24 ++ bro.config.js | 18 +- locales/ru.json | 1 + package-lock.json | 123 +++++- package.json | 5 +- src/__data__/api/api.ts | 139 ++++++ src/__data__/kc.ts | 7 + src/__data__/slices/user.ts | 10 + src/__data__/store.ts | 24 ++ src/__data__/types.ts | 399 ++++++++++++++++++ src/__data__/urls.ts | 17 +- src/app.tsx | 27 +- src/components/admin/ABTestPanel.tsx | 200 +++++++++ src/components/personal/ActivityHeatmap.tsx | 54 +++ src/components/personal/CheckStatusView.tsx | 56 +++ src/components/personal/MobileDashboard.tsx | 41 ++ src/components/personal/PersonalDashboard.tsx | 193 +++++++++ src/components/personal/ProgressChart.tsx | 47 +++ src/components/personal/ResultView.tsx | 69 +++ src/components/personal/StatCard.tsx | 34 ++ src/components/personal/TaskWorkspace.tsx | 84 ++++ src/components/personal/TimelineChart.tsx | 55 +++ src/components/personal/index.ts | 10 + src/context/ChallengeContext.tsx | 237 +++++++++++ src/dashboard.tsx | 12 +- src/hooks/useSubmission.ts | 156 +++++++ src/index.tsx | 63 ++- src/pages/admin/AdminDashboard.tsx | 252 +++++++++++ src/pages/admin/index.ts | 4 + src/pages/index.ts | 3 +- src/pages/main/main.tsx | 176 +++++++- src/utils/analytics/index.ts | 362 ++++++++++++++++ src/utils/authLoopGuard.ts | 59 +++ src/utils/drafts.ts | 32 ++ src/utils/errors.ts | 28 ++ src/utils/events.ts | 50 +++ src/utils/polling.ts | 71 ++++ stubs/api/data/auth.json | 4 + stubs/api/data/chains.json | 27 ++ stubs/api/data/queue-status.json | 57 +++ stubs/api/data/submissions.json | 59 +++ stubs/api/data/submit.json | 4 + stubs/api/data/system-stats.json | 20 + stubs/api/data/user-stats.json | 59 +++ stubs/api/data/user-submissions.json | 43 ++ stubs/api/index.js | 96 ++++- tsconfig.json | 21 +- 47 files changed, 3465 insertions(+), 67 deletions(-) create mode 100644 @types/index.d.ts create mode 100644 locales/ru.json create mode 100644 src/__data__/api/api.ts create mode 100644 src/__data__/kc.ts create mode 100644 src/__data__/slices/user.ts create mode 100644 src/__data__/store.ts create mode 100644 src/__data__/types.ts create mode 100644 src/components/admin/ABTestPanel.tsx create mode 100644 src/components/personal/ActivityHeatmap.tsx create mode 100644 src/components/personal/CheckStatusView.tsx create mode 100644 src/components/personal/MobileDashboard.tsx create mode 100644 src/components/personal/PersonalDashboard.tsx create mode 100644 src/components/personal/ProgressChart.tsx create mode 100644 src/components/personal/ResultView.tsx create mode 100644 src/components/personal/StatCard.tsx create mode 100644 src/components/personal/TaskWorkspace.tsx create mode 100644 src/components/personal/TimelineChart.tsx create mode 100644 src/components/personal/index.ts create mode 100644 src/context/ChallengeContext.tsx create mode 100644 src/hooks/useSubmission.ts create mode 100644 src/pages/admin/AdminDashboard.tsx create mode 100644 src/pages/admin/index.ts create mode 100644 src/utils/analytics/index.ts create mode 100644 src/utils/authLoopGuard.ts create mode 100644 src/utils/drafts.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/events.ts create mode 100644 src/utils/polling.ts create mode 100644 stubs/api/data/auth.json create mode 100644 stubs/api/data/chains.json create mode 100644 stubs/api/data/queue-status.json create mode 100644 stubs/api/data/submissions.json create mode 100644 stubs/api/data/submit.json create mode 100644 stubs/api/data/system-stats.json create mode 100644 stubs/api/data/user-stats.json create mode 100644 stubs/api/data/user-submissions.json diff --git a/@types/index.d.ts b/@types/index.d.ts new file mode 100644 index 0000000..ecfbb98 --- /dev/null +++ b/@types/index.d.ts @@ -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 diff --git a/bro.config.js b/bro.config.js index 80a9b85..f412954 100644 --- a/bro.config.js +++ b/bro.config.js @@ -1,3 +1,5 @@ +const webpack = require('webpack'); + const pkg = require('./package') module.exports = { @@ -5,19 +7,25 @@ module.exports = { webpackConfig: { output: { 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 */ navigations: { - 'challenge-pl.main': '/challenge-pl', - 'link.challenge-pl.auth': '/auth' + 'challenge.main': '/challenge', }, features: { - 'challenge-pl': { + 'challenge': { // add your features here in the format [featureName]: { value: string } }, }, config: { - 'challenge-pl.api': '/api' + 'challenge.api': '/api' } } diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/locales/ru.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6b721df..0a2e393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "challenge-pl", + "name": "challenge", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "challenge-pl", + "name": "challenge", "version": "0.0.0", "license": "ISC", "dependencies": { @@ -13,6 +13,7 @@ "@chakra-ui/react": "^3.2.0", "@emotion/react": "^11.13.5", "@eslint/js": "^9.11.0", + "@reduxjs/toolkit": "^2.9.2", "@stylistic/eslint-plugin": "^2.8.0", "@types/node": "^22.18.13", "@types/react": "^18.3.12", @@ -21,8 +22,10 @@ "eslint-plugin-react": "^7.36.1", "express": "^4.19.2", "globals": "^15.9.0", + "keycloak-js": "^26.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.23.1", "typescript-eslint": "^8.6.0" } @@ -2501,6 +2504,42 @@ "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": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -2510,6 +2549,18 @@ "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": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.8.0.tgz", @@ -2626,6 +2677,12 @@ "@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": { "version": "1.18.8", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", @@ -8135,6 +8192,15 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -9481,6 +9547,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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": { "version": "6.23.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", @@ -9583,6 +9672,21 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9687,6 +9791,12 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11117,6 +11227,15 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 79a0a20..3371ed1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "challenge-pl", + "name": "challenge", "version": "0.0.0", "description": "", "main": "./src/index.tsx", @@ -24,6 +24,7 @@ "@chakra-ui/react": "^3.2.0", "@emotion/react": "^11.13.5", "@eslint/js": "^9.11.0", + "@reduxjs/toolkit": "^2.9.2", "@stylistic/eslint-plugin": "^2.8.0", "@types/node": "^22.18.13", "@types/react": "^18.3.12", @@ -32,8 +33,10 @@ "eslint-plugin-react": "^7.36.1", "express": "^4.19.2", "globals": "^15.9.0", + "keycloak-js": "^26.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.23.1", "typescript-eslint": "^8.6.0" } diff --git a/src/__data__/api/api.ts b/src/__data__/api/api.ts new file mode 100644 index 0000000..9e90217 --- /dev/null +++ b/src/__data__/api/api.ts @@ -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({ + query: (body) => ({ + url: '/auth', + method: 'POST', + body, + }), + }), + getChains: builder.query({ + query: () => ({ + url: '/chains', + method: 'GET', + }), + providesTags: ['Chains'], + }), + getChain: builder.query({ + query: (chainId) => ({ + url: `/chain/${chainId}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [{ type: 'Chain', id: arg }], + }), + submitSolution: builder.mutation({ + query: (body) => ({ + url: '/submit', + method: 'POST', + body, + }), + invalidatesTags: ['Queue', 'Submissions', 'UserStats'], + }), + checkQueueStatus: builder.query({ + query: (queueId) => ({ + url: `/check-status/${queueId}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [{ type: 'Queue', id: arg }], + }), + getUserStats: builder.query({ + query: (userId) => ({ + url: `/user/${userId}/stats`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [{ type: 'UserStats', id: arg }], + }), + getUserSubmissions: builder.query({ + query: ({ userId, taskId }) => ({ + url: `/user/${userId}/submissions${taskId ? `?taskId=${taskId}` : ''}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [{ type: 'Submissions', id: arg.userId }], + }), + getSystemStats: builder.query({ + query: () => ({ + url: '/stats', + method: 'GET', + }), + providesTags: ['SystemStats'], + }), + getTask: builder.query({ + query: (taskId) => ({ + url: `/task/${taskId}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [{ type: 'Submissions', id: `task-${arg}` }], + }), + getAllSubmissions: builder.query({ + 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 diff --git a/src/__data__/kc.ts b/src/__data__/kc.ts new file mode 100644 index 0000000..1f66bda --- /dev/null +++ b/src/__data__/kc.ts @@ -0,0 +1,7 @@ +import Keycloak from 'keycloak-js' + +export const keycloak = new Keycloak({ + url: KC_URL, + realm: KC_REALM, + clientId: KC_CLIENT_ID, +}) diff --git a/src/__data__/slices/user.ts b/src/__data__/slices/user.ts new file mode 100644 index 0000000..120c53f --- /dev/null +++ b/src/__data__/slices/user.ts @@ -0,0 +1,10 @@ +import { createSlice } from '@reduxjs/toolkit' + +import { UserData } from '../types' + +export const userSlice = createSlice({ + name: 'user', + initialState: null as UserData, + reducers: { + } +}) diff --git a/src/__data__/store.ts b/src/__data__/store.ts new file mode 100644 index 0000000..f0c6f9c --- /dev/null +++ b/src/__data__/store.ts @@ -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 +export type RootState = ReturnType +export type AppDispatch = AppStore['dispatch'] + +export const useAppSelector: TypedUseSelectorHook = useSelector +export const useAppDispatch = () => useDispatch() diff --git a/src/__data__/types.ts b/src/__data__/types.ts new file mode 100644 index 0000000..80a822c --- /dev/null +++ b/src/__data__/types.ts @@ -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 + 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 { + 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; + /** Полное имя пользователя */ + name: string; + /** Предпочитаемое имя пользователя */ + preferred_username: string; + /** Имя пользователя */ + given_name: string; + /** Фамилия пользователя */ + family_name: string; + /** Email пользователя */ + email: string; +} + +/** + * Базовый ответ API + */ +export type BaseResponse = { + /** Успешность операции */ + success: boolean; + /** Данные ответа */ + body: Data; +}; + diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index 5e55827..c8df917 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -4,12 +4,25 @@ import pkg from '../../package.json' const baseUrl = getNavigationValue(`${pkg.name}.main`) 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 = { baseUrl, 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), }, } diff --git a/src/app.tsx b/src/app.tsx index f7cc246..c73ec27 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,17 +1,30 @@ -import React from 'react' +import React, { useMemo } from 'react' import { BrowserRouter } from 'react-router-dom' +import { Provider as ReduxProvider } from 'react-redux' 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 = () => { - return ( +interface AppProps { + store?: AppStore +} + +const App = ({ store }: AppProps) => { + const resolvedStore = useMemo(() => store ?? createStore(), [store]) + + const content = ( - - - + + + + + ) + + return {content} } export default App diff --git a/src/components/admin/ABTestPanel.tsx b/src/components/admin/ABTestPanel.tsx new file mode 100644 index 0000000..33e7295 --- /dev/null +++ b/src/components/admin/ABTestPanel.tsx @@ -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 +}) => ( + + + {label} + + onChange(Number.isNaN(val) ? 0 : val)}> + + + {suffix && ( + + {suffix} + + )} + +) + +export const ABTestPanel = () => { + const [variantA, setVariantA] = useState(createVariantState) + const [variantB, setVariantB] = useState(createVariantState) + const [comparison, setComparison] = useState | 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 ( + + + A/B тест: сравнение вариантов + + + + + + Вариант A + + + setVariantA((prev) => ({ ...prev, submissionRate: value }))} + suffix="Процент пользователей, отправивших хотя бы одно решение" + /> + setVariantA((prev) => ({ ...prev, completionRate: value }))} + /> + setVariantA((prev) => ({ ...prev, retryRate: value }))} + /> + setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))} + /> + setVariantA((prev) => ({ ...prev, sessionDuration: value }))} + /> + + + + + + Вариант B + + + setVariantB((prev) => ({ ...prev, submissionRate: value }))} + /> + setVariantB((prev) => ({ ...prev, completionRate: value }))} + /> + setVariantB((prev) => ({ ...prev, retryRate: value }))} + /> + setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))} + /> + setVariantB((prev) => ({ ...prev, sessionDuration: value }))} + /> + + + + + + + {comparison && ( + + + Результат сравнения + + + + Δ Submission Rate + {comparison.submissionRateDiff.toFixed(1)}% + Положительное значение — рост у варианта B + + + Δ Completion Rate + {comparison.completionRateDiff.toFixed(1)}% + Положительное значение — рост у варианта B + + + + + Победитель + Вариант {comparison.winner} + Основано на сравнении коэффициента завершения + + + )} + + ) +} + diff --git a/src/components/personal/ActivityHeatmap.tsx b/src/components/personal/ActivityHeatmap.tsx new file mode 100644 index 0000000..a4ef0cb --- /dev/null +++ b/src/components/personal/ActivityHeatmap.tsx @@ -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 ( + + Нет активности по датам + + ) + } + + return ( + + + Активность по дням + + + + {data.dates.map((day) => ( + + + + ))} + + + ) +} + diff --git a/src/components/personal/CheckStatusView.tsx b/src/components/personal/CheckStatusView.tsx new file mode 100644 index 0000000..55783dd --- /dev/null +++ b/src/components/personal/CheckStatusView.tsx @@ -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 ( + + + + Ожидание в очереди + + {typeof status.position === 'number' && ( + + Позиция в очереди: {status.position} + + )} + + ) + } + + if (status.status === 'in_progress') { + return ( + + + + Проверяем ваше решение... + + + Это может занять несколько секунд + + + ) + } + + if (status.status === 'error') { + return ( + + + Ошибка проверки + + + {status.error ?? 'Не удалось завершить проверку. Попробуйте позже.'} + + + ) + } + + return null +} + diff --git a/src/components/personal/MobileDashboard.tsx b/src/components/personal/MobileDashboard.tsx new file mode 100644 index 0000000..ea636da --- /dev/null +++ b/src/components/personal/MobileDashboard.tsx @@ -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 ( + + + + + + Сегодня выполнено + {personalDashboard.overview.tasksCompleted} + Общий прогресс: {Math.round(personalDashboard.overview.completionPercentage)}% + + + + + + Текущая цепочка + {personalDashboard.activeChains[0]?.name ?? '—'} + + {personalDashboard.activeChains.length > 0 + ? `${Math.round(personalDashboard.activeChains[0].progress)}% завершено` + : 'Нет активных цепочек'} + + + + + + + ) +} + diff --git a/src/components/personal/PersonalDashboard.tsx b/src/components/personal/PersonalDashboard.tsx new file mode 100644 index 0000000..8f54ec2 --- /dev/null +++ b/src/components/personal/PersonalDashboard.tsx @@ -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 ( + + Загрузка статистики... + + ) + } + + return ( + + + Персональная статистика + + + + + + + + + + + {progressChartData && } + + + + + + + + + + + + + Рекомендации + + {personalDashboard.recommendations.length === 0 ? ( + Новых рекомендаций пока нет. + ) : ( + + {personalDashboard.recommendations.map((recommendation) => ( + + {recommendation.message} + + Тип: {recommendation.type} + + + ))} + + )} + + + + + + + Активные цепочки + + + {personalDashboard.activeChains.length === 0 ? ( + Начните новую цепочку, чтобы увидеть прогресс. + ) : ( + + {personalDashboard.activeChains.map((chainStat) => { + const chain = chains.find((item) => item.id === chainStat.chainId) ?? null + const nextTask = chainStat.nextTask ?? chain?.tasks[chainStat.completedTasks] ?? null + + return ( + + + + {chainStat.name} + + {chainStat.completedTasks} / {chain ? chain.tasks.length : chainStat.completedTasks} выполнено · {formatNumber(chainStat.progress)}% + + {nextTask && ( + + Следующее задание: {nextTask.title} + + )} + + + {nextTask && chain && ( + + )} + + + ) + })} + + )} + + + + + Последние достижения + + {personalDashboard.recentAchievements.length === 0 ? ( + Достижений пока нет. Продолжайте работать! + ) : ( + + {personalDashboard.recentAchievements.map((achievement) => ( + + {achievement.taskTitle} + + {achievement.type} · {new Date(achievement.timestamp).toLocaleString()} + + + ))} + + )} + + + {isSubmissionsLoading && ( + + Обновляем историю отправок... + + )} + + ) +} + diff --git a/src/components/personal/ProgressChart.tsx b/src/components/personal/ProgressChart.tsx new file mode 100644 index 0000000..b87bc8e --- /dev/null +++ b/src/components/personal/ProgressChart.tsx @@ -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 ( + + + Прогресс по заданиям + + + + {PROGRESS_KEYS.map(({ key, label, color }) => { + const value = data[key] + const percent = total ? Math.round((value / total) * 100) : 0 + + return ( + + + {label} + + {value} ({percent}%) + + + + + ) + })} + + + ) +} + diff --git a/src/components/personal/ResultView.tsx b/src/components/personal/ResultView.tsx new file mode 100644 index 0000000..e260798 --- /dev/null +++ b/src/components/personal/ResultView.tsx @@ -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 ( + + + {isAccepted ? '✅' : '❌'} + + + {isAccepted ? 'Задание принято!' : 'Требуется доработка'} + + + {submission.feedback && ( + + + Комментарий: + + + {submission.feedback} + + + )} + + + Попытка №{submission.attemptNumber}. Время проверки: {formatDuration(submission)} + + + + {!isAccepted && onRetry && ( + + )} + {isAccepted && onNext && ( + + )} + + + ) +} + diff --git a/src/components/personal/StatCard.tsx b/src/components/personal/StatCard.tsx new file mode 100644 index 0000000..f549837 --- /dev/null +++ b/src/components/personal/StatCard.tsx @@ -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 ( + + + + {title} + + {icon && ( + + {icon} + + )} + + + + {value} + + + {change !== undefined && trend && ( + + {trend === 'up' ? '↑' : '↓'} {Math.abs(change)}% + + )} + + ) +} + diff --git a/src/components/personal/TaskWorkspace.tsx b/src/components/personal/TaskWorkspace.tsx new file mode 100644 index 0000000..0018440 --- /dev/null +++ b/src/components/personal/TaskWorkspace.tsx @@ -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 + } + + if (finalSubmission) { + return ( + + ) + } + + return ( + + + + {task.title} + + + {task.description} + + + + + + Ваше решение + +