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} + + + + + + Ваше решение + +