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

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-11-03 12:55:34 +03:00
parent 3a65307fd0
commit 624280ab5e
47 changed files with 3465 additions and 67 deletions

24
@types/index.d.ts vendored Normal file
View 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

View File

@ -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'
}
}

1
locales/ru.json Normal file
View File

@ -0,0 +1 @@
{}

123
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

139
src/__data__/api/api.ts Normal file
View 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
View 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,
})

View 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
View 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
View 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;
};

View File

@ -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),
},
}

View File

@ -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 = (
<BrowserRouter>
<Provider>
<Dashboard />
</Provider>
<ThemeProvider>
<ChallengeProvider>
<Dashboard />
</ChallengeProvider>
</ThemeProvider>
</BrowserRouter>
)
return <ReduxProvider store={resolvedStore}>{content}</ReduxProvider>
}
export default App

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'

View 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
}

View File

@ -2,7 +2,7 @@ import React, { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { URLs } from './__data__/urls'
import { MainPage } from './pages'
import { AdminPage, MainPage } from './pages'
const PageWrapper = ({ children }: React.PropsWithChildren) => (
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
@ -19,6 +19,16 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
{URLs.admin.isOn && (
<Route
path={URLs.admin.url}
element={
<PageWrapper>
<AdminPage />
</PageWrapper>
}
/>
)}
</Routes>
)
}

156
src/hooks/useSubmission.ts Normal file
View 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],
)
}

View File

@ -1,24 +1,65 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/display-name */
import React from 'react'
import ReactDOM from 'react-dom/client'
import i18next from 'i18next'
import { i18nextReactInitConfig } from '@brojs/cli'
import App from './app'
export default () => <App/>
import { keycloak } from "./__data__/kc"
import { createStore } from "./__data__/store"
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
i18next.t = i18next.t.bind(i18next)
const i18nextPromise = i18nextReactInitConfig(i18next)
export default (props) => <App {...props} />
let rootElement: ReactDOM.Root
export const mount = (Component, element = document.getElementById('app')) => {
rootElement = ReactDOM.createRoot(element)
rootElement.render(<Component/>)
export const mount = async (Component, element = document.getElementById('app')) => {
let user = null
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) {
// @ts-ignore
module.hot.accept('./app', ()=> {
rootElement.render(<Component/>)
rootElement.render(<Component store={store} />)
})
}
}

View 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
View File

@ -0,0 +1,4 @@
import { AdminDashboard } from './AdminDashboard'
export default AdminDashboard

View File

@ -1,3 +1,4 @@
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'))

View File

@ -1,28 +1,158 @@
import React from 'react'
import { Grid, GridItem } from '@chakra-ui/react'
import React, { useEffect, useMemo, useRef, useState } from '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 = () => {
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 (
<Grid
h="100%"
bgColor="gray.300"
templateAreas={{
md: `"header header"
"aside main"
"footer footer"`,
sm: `"header"
"main"
"aside"
"footer"`,
}}
gridTemplateRows={{ sm: '1fr', md: '50px 1fr 30px' }}
gridTemplateColumns={{ sm: '1fr', md: '150px 1fr' }}
gap={4}
>
<GridItem bgColor="green.100" gridArea="header">header</GridItem>
<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>
</Grid>
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
{notification && (
<Alert status={notification.status} borderRadius="md">
<AlertIndicator />
<Box ml={3}>
<Text fontWeight="semibold">{notification.title}</Text>
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
</Box>
</Alert>
)}
{isOffline && (
<Alert status="warning" borderRadius="md">
<AlertIndicator />
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
</Alert>
)}
<Box>
<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>
)
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"ok": true,
"userId": "user-frontend-001"
}

View 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"
}
]
}
]

View 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
}
}
}

View 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
}
]

View File

@ -0,0 +1,4 @@
{
"queueId": "queue-frontend-003",
"submissionId": "submission-003"
}

View 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
}
}

View 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
}
]
}
}

View 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
}
]
}

View File

@ -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

View File

@ -1,25 +1,26 @@
{
"compilerOptions": {
"lib": [
"dom",
"es2017"
],
"lib": ["dom", "es2017"],
"outDir": "./dist/",
"sourceMap": true,
"esModuleInterop": true,
"noImplicitAny": false,
"module": "esnext",
"moduleResolution": "node",
"target": "es6",
"jsx": "react",
"typeRoots": ["node_modules/@types", "src/typings"],
"types" : ["webpack-env", "node"],
"resolveJsonModule": true
"typeRoots": ["node_modules/@types", "./@types"],
"types": ["webpack-env", "node"],
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"skipLibCheck": true,
},
"types": [
"@types/*"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"node_modules/@types/jest"
]
}
]
}