Add detailed statistics API v2 documentation and implement frontend components for displaying statistics
This commit is contained in:
parent
b91ee56bf0
commit
fd55d5a214
693
docs/stats-v2-api.md
Normal file
693
docs/stats-v2-api.md
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
# API Статистики v2 - Документация для Frontend
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Эндпоинт `/challenge/stats/v2` предоставляет расширенную статистику системы проверки заданий с детальными данными для построения таблиц и прогресс-баров.
|
||||||
|
|
||||||
|
## Эндпоинт
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /challenge/stats/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
Не требуется.
|
||||||
|
|
||||||
|
### Параметры запроса
|
||||||
|
|
||||||
|
| Параметр | Тип | Обязательный | Описание |
|
||||||
|
|----------|-----|--------------|----------|
|
||||||
|
| `chainId` | string | Нет | ID цепочки для фильтрации статистики. Если указан, возвращается статистика только по заданиям из этой цепочки |
|
||||||
|
|
||||||
|
#### Примеры использования
|
||||||
|
|
||||||
|
Получить полную статистику:
|
||||||
|
```
|
||||||
|
GET /challenge/stats/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
Получить статистику только по одной цепочке:
|
||||||
|
```
|
||||||
|
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439031
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура ответа
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
body: {
|
||||||
|
// ========== БАЗОВАЯ СТАТИСТИКА (из v1) ==========
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== НОВЫЕ ДАННЫЕ V2 ==========
|
||||||
|
|
||||||
|
// Таблица заданий с детальной статистикой
|
||||||
|
tasksTable: Array<{
|
||||||
|
taskId: string,
|
||||||
|
title: string,
|
||||||
|
totalAttempts: number, // Всего попыток по всем пользователям
|
||||||
|
uniqueUsers: number, // Количество уникальных пользователей
|
||||||
|
acceptedCount: number, // Количество успешных прохождений
|
||||||
|
successRate: number, // Процент успешного прохождения (0-100)
|
||||||
|
averageAttemptsToSuccess: number // Среднее количество попыток до успеха
|
||||||
|
}>,
|
||||||
|
|
||||||
|
// Активные участники с прогрессом
|
||||||
|
activeParticipants: Array<{
|
||||||
|
userId: string,
|
||||||
|
nickname: string,
|
||||||
|
totalSubmissions: number, // Всего попыток пользователя
|
||||||
|
completedTasks: number, // Завершенных заданий
|
||||||
|
chainProgress: Array<{ // Прогресс по каждой цепочке
|
||||||
|
chainId: string,
|
||||||
|
chainName: string,
|
||||||
|
totalTasks: number, // Всего заданий в цепочке
|
||||||
|
completedTasks: number, // Завершено заданий
|
||||||
|
progressPercent: number // Процент прохождения (0-100)
|
||||||
|
}>
|
||||||
|
}>,
|
||||||
|
|
||||||
|
// Детальная информация по цепочкам
|
||||||
|
chainsDetailed: Array<{
|
||||||
|
chainId: string,
|
||||||
|
name: string,
|
||||||
|
totalTasks: number,
|
||||||
|
tasks: Array<{ // Список заданий в цепочке
|
||||||
|
taskId: string,
|
||||||
|
title: string,
|
||||||
|
description: string
|
||||||
|
}>,
|
||||||
|
participantProgress: Array<{ // Прогресс каждого участника
|
||||||
|
userId: string,
|
||||||
|
nickname: string,
|
||||||
|
taskProgress: Array<{ // Статус по каждому заданию
|
||||||
|
taskId: string,
|
||||||
|
taskTitle: string,
|
||||||
|
status: 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
|
||||||
|
}>,
|
||||||
|
completedCount: number, // Завершено заданий
|
||||||
|
progressPercent: number // Процент прохождения (0-100)
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример ответа
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"body": {
|
||||||
|
"users": 34,
|
||||||
|
"tasks": 22,
|
||||||
|
"chains": 2,
|
||||||
|
"submissions": {
|
||||||
|
"total": 39,
|
||||||
|
"accepted": 16,
|
||||||
|
"rejected": 23,
|
||||||
|
"pending": 0,
|
||||||
|
"inProgress": 0
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 2043,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 0,
|
||||||
|
"waiting": 0,
|
||||||
|
"inProgress": 0,
|
||||||
|
"maxConcurrency": 1,
|
||||||
|
"currentlyProcessing": 0
|
||||||
|
},
|
||||||
|
"tasksTable": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Создание REST API",
|
||||||
|
"totalAttempts": 45,
|
||||||
|
"uniqueUsers": 12,
|
||||||
|
"acceptedCount": 8,
|
||||||
|
"successRate": 67,
|
||||||
|
"averageAttemptsToSuccess": 2.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Работа с базой данных",
|
||||||
|
"totalAttempts": 38,
|
||||||
|
"uniqueUsers": 10,
|
||||||
|
"acceptedCount": 6,
|
||||||
|
"successRate": 60,
|
||||||
|
"averageAttemptsToSuccess": 3.1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeParticipants": [
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439021",
|
||||||
|
"nickname": "student1",
|
||||||
|
"totalSubmissions": 15,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439031",
|
||||||
|
"chainName": "Основы Backend",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439032",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 8,
|
||||||
|
"completedTasks": 0,
|
||||||
|
"progressPercent": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainsDetailed": [
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439031",
|
||||||
|
"name": "Основы Backend",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Создание REST API",
|
||||||
|
"description": "Создайте простой REST API с использованием Express.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Работа с базой данных",
|
||||||
|
"description": "Интегрируйте MongoDB в ваше приложение"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"participantProgress": [
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439021",
|
||||||
|
"nickname": "student1",
|
||||||
|
"taskProgress": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439011",
|
||||||
|
"taskTitle": "Создание REST API",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"taskTitle": "Работа с базой данных",
|
||||||
|
"status": "needs_revision"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completedCount": 1,
|
||||||
|
"progressPercent": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фильтрация по цепочке
|
||||||
|
|
||||||
|
При передаче параметра `chainId`:
|
||||||
|
|
||||||
|
1. **tasksTable** - содержит только задания из указанной цепочки
|
||||||
|
2. **activeParticipants** - включает только участников, которые делали попытки по заданиям этой цепочки. В `chainProgress` будет информация только об указанной цепочке
|
||||||
|
3. **chainsDetailed** - содержит информацию только об указанной цепочке
|
||||||
|
|
||||||
|
### Пример фильтрованного ответа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/challenge/stats/v2?chainId=507f1f77bcf86cd799439031
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"body": {
|
||||||
|
"users": 34,
|
||||||
|
"tasks": 22,
|
||||||
|
"chains": 2,
|
||||||
|
"submissions": {
|
||||||
|
"total": 39,
|
||||||
|
"accepted": 16,
|
||||||
|
"rejected": 23,
|
||||||
|
"pending": 0,
|
||||||
|
"inProgress": 0
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 2043,
|
||||||
|
"queue": { ... },
|
||||||
|
"tasksTable": [
|
||||||
|
// Только задания из цепочки 507f1f77bcf86cd799439031
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Создание REST API",
|
||||||
|
"totalAttempts": 45,
|
||||||
|
"uniqueUsers": 12,
|
||||||
|
"acceptedCount": 8,
|
||||||
|
"successRate": 67,
|
||||||
|
"averageAttemptsToSuccess": 2.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeParticipants": [
|
||||||
|
// Только участники с попытками в этой цепочке
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439021",
|
||||||
|
"nickname": "student1",
|
||||||
|
"totalSubmissions": 10,
|
||||||
|
"completedTasks": 3,
|
||||||
|
"chainProgress": [
|
||||||
|
// Только одна цепочка
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439031",
|
||||||
|
"chainName": "Основы Backend",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 3,
|
||||||
|
"progressPercent": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainsDetailed": [
|
||||||
|
// Только указанная цепочка
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439031",
|
||||||
|
"name": "Основы Backend",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"tasks": [...],
|
||||||
|
"participantProgress": [...]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование в UI компонентах
|
||||||
|
|
||||||
|
### 1. Таблица заданий
|
||||||
|
|
||||||
|
Используйте `tasksTable` для отображения статистики по каждому заданию:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React пример
|
||||||
|
function TasksTable({ tasksTable }) {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название задания</th>
|
||||||
|
<th>Попыток</th>
|
||||||
|
<th>Уникальных пользователей</th>
|
||||||
|
<th>Успешно завершено</th>
|
||||||
|
<th>% успеха</th>
|
||||||
|
<th>Среднее попыток до успеха</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasksTable.map(task => (
|
||||||
|
<tr key={task.taskId}>
|
||||||
|
<td>{task.title}</td>
|
||||||
|
<td>{task.totalAttempts}</td>
|
||||||
|
<td>{task.uniqueUsers}</td>
|
||||||
|
<td>{task.acceptedCount}</td>
|
||||||
|
<td>{task.successRate}%</td>
|
||||||
|
<td>{task.averageAttemptsToSuccess.toFixed(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Прогресс-бары участников
|
||||||
|
|
||||||
|
Используйте `activeParticipants` для отображения прогресса каждого участника:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React пример
|
||||||
|
function ParticipantProgress({ activeParticipants }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{activeParticipants.map(participant => (
|
||||||
|
<div key={participant.userId} className="participant-card">
|
||||||
|
<h3>{participant.nickname}</h3>
|
||||||
|
<p>Завершено заданий: {participant.completedTasks}</p>
|
||||||
|
<p>Всего попыток: {participant.totalSubmissions}</p>
|
||||||
|
|
||||||
|
<div className="chain-progress">
|
||||||
|
{participant.chainProgress.map(chain => (
|
||||||
|
<div key={chain.chainId} className="chain-item">
|
||||||
|
<div className="chain-header">
|
||||||
|
<span>{chain.chainName}</span>
|
||||||
|
<span>{chain.completedTasks}/{chain.totalTasks}</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${chain.progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-text">{chain.progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Детальный прогресс по цепочкам
|
||||||
|
|
||||||
|
Используйте `chainsDetailed` для отображения детального прогресса всех участников в рамках каждой цепочки:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React пример
|
||||||
|
function ChainDetailedView({ chainsDetailed }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{chainsDetailed.map(chain => (
|
||||||
|
<div key={chain.chainId} className="chain-detailed">
|
||||||
|
<h2>{chain.name}</h2>
|
||||||
|
<p>Заданий в цепочке: {chain.totalTasks}</p>
|
||||||
|
|
||||||
|
{/* Список заданий */}
|
||||||
|
<div className="tasks-list">
|
||||||
|
<h3>Задания:</h3>
|
||||||
|
{chain.tasks.map(task => (
|
||||||
|
<div key={task.taskId} className="task-item">
|
||||||
|
<h4>{task.title}</h4>
|
||||||
|
<p>{task.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Прогресс участников */}
|
||||||
|
<div className="participants-progress">
|
||||||
|
<h3>Прогресс участников:</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Участник</th>
|
||||||
|
{chain.tasks.map(task => (
|
||||||
|
<th key={task.taskId}>{task.title}</th>
|
||||||
|
))}
|
||||||
|
<th>Прогресс</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{chain.participantProgress.map(participant => (
|
||||||
|
<tr key={participant.userId}>
|
||||||
|
<td>{participant.nickname}</td>
|
||||||
|
{participant.taskProgress.map(taskProg => (
|
||||||
|
<td key={taskProg.taskId}>
|
||||||
|
<StatusBadge status={taskProg.status} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td>{participant.progressPercent}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для отображения статуса
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const statusConfig = {
|
||||||
|
'not_started': { label: 'Не начато', color: 'gray' },
|
||||||
|
'pending': { label: 'Ожидает', color: 'yellow' },
|
||||||
|
'in_progress': { label: 'В процессе', color: 'blue' },
|
||||||
|
'needs_revision': { label: 'Доработка', color: 'orange' },
|
||||||
|
'completed': { label: 'Завершено', color: 'green' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`badge badge-${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации по использованию
|
||||||
|
|
||||||
|
### Производительность
|
||||||
|
|
||||||
|
- Эндпоинт выполняет множество агрегаций, поэтому может работать медленно при большом количестве данных
|
||||||
|
- Рекомендуется кэшировать результат на клиенте на 30-60 секунд
|
||||||
|
- Используйте loading индикаторы во время загрузки данных
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Пример с кэшированием
|
||||||
|
function useStatsV2(chainId?: string) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const lastFetch = useRef(0);
|
||||||
|
|
||||||
|
const fetchStats = async (force = false) => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Кэш на 60 секунд
|
||||||
|
if (!force && data && (now - lastFetch.current) < 60000) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const url = chainId
|
||||||
|
? `/challenge/stats/v2?chainId=${chainId}`
|
||||||
|
: '/challenge/stats/v2';
|
||||||
|
const response = await fetch(url);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.body);
|
||||||
|
lastFetch.current = now;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [chainId]); // Перезагружаем при смене цепочки
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: () => fetchStats(true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование
|
||||||
|
function ChainStatistics() {
|
||||||
|
const [selectedChainId, setSelectedChainId] = useState<string | undefined>();
|
||||||
|
const { data, loading } = useStatsV2(selectedChainId);
|
||||||
|
|
||||||
|
if (loading) return <div>Загрузка...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedChainId || ''}
|
||||||
|
onChange={(e) => setSelectedChainId(e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Все цепочки</option>
|
||||||
|
{data?.chainsDetailed.map(chain => (
|
||||||
|
<option key={chain.chainId} value={chain.chainId}>
|
||||||
|
{chain.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<TasksTable tasksTable={data?.tasksTable} />
|
||||||
|
<ParticipantProgress activeParticipants={data?.activeParticipants} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фильтрация и сортировка
|
||||||
|
|
||||||
|
Все массивы данных можно фильтровать и сортировать на клиенте:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Сортировка таблицы заданий по проценту успеха
|
||||||
|
const sortedTasks = [...tasksTable].sort((a, b) => b.successRate - a.successRate);
|
||||||
|
|
||||||
|
// Фильтрация активных участников с прогрессом > 50%
|
||||||
|
const activeStudents = activeParticipants.filter(p =>
|
||||||
|
p.chainProgress.some(c => c.progressPercent > 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Поиск участника по имени
|
||||||
|
const searchParticipant = (query) =>
|
||||||
|
activeParticipants.filter(p =>
|
||||||
|
p.nickname.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Визуализация данных
|
||||||
|
|
||||||
|
Для построения графиков и диаграмм используйте библиотеки типа:
|
||||||
|
- **Chart.js** / **Recharts** - для графиков прогресса
|
||||||
|
- **AG Grid** / **TanStack Table** - для таблиц с сортировкой
|
||||||
|
- **React Progress Bar** - для прогресс-баров
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Пример с Chart.js
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
function TasksSuccessChart({ tasksTable }) {
|
||||||
|
const data = {
|
||||||
|
labels: tasksTable.map(t => t.title),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Процент успеха',
|
||||||
|
data: tasksTable.map(t => t.successRate),
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Когда использовать фильтрацию по цепочке
|
||||||
|
|
||||||
|
### Используйте `chainId` когда:
|
||||||
|
|
||||||
|
1. **Страница отдельной цепочки** - пользователь просматривает детали конкретной цепочки
|
||||||
|
```tsx
|
||||||
|
function ChainDetailsPage({ chainId }) {
|
||||||
|
const { data } = useStatsV2(chainId);
|
||||||
|
return <ChainDashboard data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Улучшение производительности** - когда нужны данные только по одной цепочке, фильтрация на сервере работает быстрее
|
||||||
|
|
||||||
|
3. **Фокус на конкретной программе** - преподаватель хочет видеть прогресс по конкретному курсу/модулю
|
||||||
|
|
||||||
|
### НЕ используйте `chainId` когда:
|
||||||
|
|
||||||
|
1. **Общий дашборд** - нужна статистика по всем цепочкам
|
||||||
|
2. **Сравнение цепочек** - нужно показать метрики по всем цепочкам одновременно
|
||||||
|
3. **Общая аналитика** - нужны агрегированные данные по всей системе
|
||||||
|
|
||||||
|
## Различия между v1 и v2
|
||||||
|
|
||||||
|
| Параметр | v1 (/stats) | v2 (/stats/v2) | v2 с chainId |
|
||||||
|
|----------|-------------|----------------|--------------|
|
||||||
|
| Базовая статистика | ✅ | ✅ | ✅ |
|
||||||
|
| Таблица заданий | ❌ | ✅ (все) | ✅ (фильтр) |
|
||||||
|
| Прогресс участников | ❌ | ✅ (все) | ✅ (фильтр) |
|
||||||
|
| Детальный прогресс по цепочкам | ❌ | ✅ (все) | ✅ (одна) |
|
||||||
|
| Статистика по попыткам | Общая | Детальная | Детальная (фильтр) |
|
||||||
|
| Скорость работы | Быстро | Медленнее | Средняя |
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function fetchStatsV2(chainId?: string) {
|
||||||
|
try {
|
||||||
|
const url = chainId
|
||||||
|
? `/challenge/stats/v2?chainId=${chainId}`
|
||||||
|
: '/challenge/stats/v2';
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error?.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.body;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch stats v2:', error);
|
||||||
|
|
||||||
|
// Специальная обработка для неверного chainId
|
||||||
|
if (error.message.includes('Chain not found')) {
|
||||||
|
showNotification('Цепочка не найдена', 'error');
|
||||||
|
// Перенаправить на страницу всех цепочек
|
||||||
|
window.location.href = '/chains';
|
||||||
|
} else {
|
||||||
|
showNotification('Не удалось загрузить статистику', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Типичные ошибки
|
||||||
|
|
||||||
|
| Ошибка | Причина | Решение |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `Chain not found` | Передан несуществующий `chainId` | Проверить ID цепочки, показать ошибку пользователю |
|
||||||
|
| `500 Internal Server Error` | Проблема на сервере | Показать общее сообщение об ошибке, повторить запрос |
|
||||||
|
| Timeout | Слишком много данных | Использовать фильтрацию по `chainId` для уменьшения объема данных |
|
||||||
|
|
||||||
|
## Дополнительные возможности
|
||||||
|
|
||||||
|
### Экспорт данных
|
||||||
|
|
||||||
|
Данные можно экспортировать в CSV или Excel для анализа:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function exportToCSV(tasksTable) {
|
||||||
|
const headers = ['Название', 'Попыток', 'Пользователей', 'Успешно', '% успеха', 'Средние попытки'];
|
||||||
|
const rows = tasksTable.map(task => [
|
||||||
|
task.title,
|
||||||
|
task.totalAttempts,
|
||||||
|
task.uniqueUsers,
|
||||||
|
task.acceptedCount,
|
||||||
|
task.successRate,
|
||||||
|
task.averageAttemptsToSuccess
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map(row => row.join(','))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'tasks-statistics.csv';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
Эндпоинт `/stats/v2` предоставляет все необходимые данные для построения информативных дашбордов с таблицами заданий и прогресс-барами участников. Комбинируйте различные части данных для создания удобных UI компонентов.
|
||||||
|
|
||||||
|
Для дополнительной информации см. также:
|
||||||
|
- [CHALLENGE_FRONTEND_GUIDE.md](./CHALLENGE_FRONTEND_GUIDE.md) - общее руководство по работе с Challenge API
|
||||||
|
- [CHALLENGE_REACT_EXAMPLE.md](./CHALLENGE_REACT_EXAMPLE.md) - примеры React компонентов
|
||||||
|
|
||||||
@ -178,6 +178,7 @@
|
|||||||
"challenge.admin.submissions.details.close": "Close",
|
"challenge.admin.submissions.details.close": "Close",
|
||||||
"challenge.admin.layout.title": "Challenge Admin",
|
"challenge.admin.layout.title": "Challenge Admin",
|
||||||
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
||||||
|
"challenge.admin.layout.nav.detailed.stats": "Detailed Statistics",
|
||||||
"challenge.admin.layout.nav.tasks": "Tasks",
|
"challenge.admin.layout.nav.tasks": "Tasks",
|
||||||
"challenge.admin.layout.nav.chains": "Chains",
|
"challenge.admin.layout.nav.chains": "Chains",
|
||||||
"challenge.admin.layout.nav.users": "Users",
|
"challenge.admin.layout.nav.users": "Users",
|
||||||
@ -188,5 +189,46 @@
|
|||||||
"challenge.admin.common.error.default": "An error occurred while loading data",
|
"challenge.admin.common.error.default": "An error occurred while loading data",
|
||||||
"challenge.admin.common.retry": "Try again",
|
"challenge.admin.common.retry": "Try again",
|
||||||
"challenge.admin.common.confirm": "Confirm",
|
"challenge.admin.common.confirm": "Confirm",
|
||||||
"challenge.admin.common.close": "Close"
|
"challenge.admin.common.close": "Close",
|
||||||
|
"challenge.admin.detailed.stats.title": "Detailed Statistics",
|
||||||
|
"challenge.admin.detailed.stats.loading": "Loading detailed statistics...",
|
||||||
|
"challenge.admin.detailed.stats.load.error": "Failed to load detailed statistics",
|
||||||
|
"challenge.admin.detailed.stats.auto.refresh": "Auto-refreshes every 5 seconds",
|
||||||
|
"challenge.admin.detailed.stats.select.chain": "Select a chain to view detailed statistics",
|
||||||
|
"challenge.admin.detailed.stats.no.chains": "No chains available",
|
||||||
|
"challenge.admin.detailed.stats.chain.card.click": "Click to view detailed statistics for this chain",
|
||||||
|
"challenge.admin.detailed.stats.chain.card.tasks": "tasks",
|
||||||
|
"challenge.admin.detailed.stats.back.to.chains": "Back to chain selection",
|
||||||
|
"challenge.admin.detailed.stats.overview.title": "Overview",
|
||||||
|
"challenge.admin.detailed.stats.overview.users": "Users",
|
||||||
|
"challenge.admin.detailed.stats.overview.tasks": "Tasks",
|
||||||
|
"challenge.admin.detailed.stats.overview.chains": "Chains",
|
||||||
|
"challenge.admin.detailed.stats.overview.total.attempts": "Total attempts",
|
||||||
|
"challenge.admin.detailed.stats.overview.successful": "Successful",
|
||||||
|
"challenge.admin.detailed.stats.overview.in.progress.pending": "In progress / Pending",
|
||||||
|
"challenge.admin.detailed.stats.overview.avg.check.time": "Average check time",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.title": "Task Statistics",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.empty": "No data to display",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.task.name": "Task name",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.attempts": "Attempts",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.users": "Users",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.completed": "Completed",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.success.rate": "Success %",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.avg.attempts": "Avg attempts",
|
||||||
|
"challenge.admin.detailed.stats.participants.title": "Active Participants",
|
||||||
|
"challenge.admin.detailed.stats.participants.empty": "No active participants",
|
||||||
|
"challenge.admin.detailed.stats.participants.completed": "Completed:",
|
||||||
|
"challenge.admin.detailed.stats.participants.attempts": "Attempts:",
|
||||||
|
"challenge.admin.detailed.stats.participants.no.progress": "No chain progress",
|
||||||
|
"challenge.admin.detailed.stats.chains.title": "Detailed Chain Progress",
|
||||||
|
"challenge.admin.detailed.stats.chains.empty": "No chain data",
|
||||||
|
"challenge.admin.detailed.stats.chains.total.tasks": "Total tasks:",
|
||||||
|
"challenge.admin.detailed.stats.chains.participant": "Participant",
|
||||||
|
"challenge.admin.detailed.stats.chains.progress": "Progress",
|
||||||
|
"challenge.admin.detailed.stats.chains.no.participants": "No participants in this chain",
|
||||||
|
"challenge.admin.detailed.stats.status.not.started": "Not started",
|
||||||
|
"challenge.admin.detailed.stats.status.pending": "Pending",
|
||||||
|
"challenge.admin.detailed.stats.status.in.progress": "In progress",
|
||||||
|
"challenge.admin.detailed.stats.status.needs.revision": "Needs revision",
|
||||||
|
"challenge.admin.detailed.stats.status.completed": "Completed"
|
||||||
}
|
}
|
||||||
@ -177,6 +177,7 @@
|
|||||||
"challenge.admin.submissions.details.close": "Закрыть",
|
"challenge.admin.submissions.details.close": "Закрыть",
|
||||||
"challenge.admin.layout.title": "Challenge Admin",
|
"challenge.admin.layout.title": "Challenge Admin",
|
||||||
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
||||||
|
"challenge.admin.layout.nav.detailed.stats": "Детальная статистика",
|
||||||
"challenge.admin.layout.nav.tasks": "Задания",
|
"challenge.admin.layout.nav.tasks": "Задания",
|
||||||
"challenge.admin.layout.nav.chains": "Цепочки",
|
"challenge.admin.layout.nav.chains": "Цепочки",
|
||||||
"challenge.admin.layout.nav.users": "Пользователи",
|
"challenge.admin.layout.nav.users": "Пользователи",
|
||||||
@ -187,5 +188,46 @@
|
|||||||
"challenge.admin.common.error.default": "Произошла ошибка при загрузке данных",
|
"challenge.admin.common.error.default": "Произошла ошибка при загрузке данных",
|
||||||
"challenge.admin.common.retry": "Попробовать снова",
|
"challenge.admin.common.retry": "Попробовать снова",
|
||||||
"challenge.admin.common.confirm": "Подтвердить",
|
"challenge.admin.common.confirm": "Подтвердить",
|
||||||
"challenge.admin.common.close": "Закрыть"
|
"challenge.admin.common.close": "Закрыть",
|
||||||
|
"challenge.admin.detailed.stats.title": "Детальная статистика",
|
||||||
|
"challenge.admin.detailed.stats.loading": "Загрузка детальной статистики...",
|
||||||
|
"challenge.admin.detailed.stats.load.error": "Не удалось загрузить детальную статистику",
|
||||||
|
"challenge.admin.detailed.stats.auto.refresh": "Обновляется автоматически каждые 5 секунд",
|
||||||
|
"challenge.admin.detailed.stats.select.chain": "Выберите цепочку для просмотра детальной статистики",
|
||||||
|
"challenge.admin.detailed.stats.no.chains": "Нет доступных цепочек",
|
||||||
|
"challenge.admin.detailed.stats.chain.card.click": "Нажмите для просмотра детальной статистики по этой цепочке",
|
||||||
|
"challenge.admin.detailed.stats.chain.card.tasks": "заданий",
|
||||||
|
"challenge.admin.detailed.stats.back.to.chains": "Назад к выбору цепочки",
|
||||||
|
"challenge.admin.detailed.stats.overview.title": "Общая статистика",
|
||||||
|
"challenge.admin.detailed.stats.overview.users": "Пользователей",
|
||||||
|
"challenge.admin.detailed.stats.overview.tasks": "Заданий",
|
||||||
|
"challenge.admin.detailed.stats.overview.chains": "Цепочек",
|
||||||
|
"challenge.admin.detailed.stats.overview.total.attempts": "Всего попыток",
|
||||||
|
"challenge.admin.detailed.stats.overview.successful": "Успешных",
|
||||||
|
"challenge.admin.detailed.stats.overview.in.progress.pending": "В процессе / Ожидают",
|
||||||
|
"challenge.admin.detailed.stats.overview.avg.check.time": "Среднее время проверки",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.title": "Статистика по заданиям",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.empty": "Нет данных для отображения",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.task.name": "Название задания",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.attempts": "Попыток",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.users": "Пользователей",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.completed": "Завершено",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.success.rate": "% успеха",
|
||||||
|
"challenge.admin.detailed.stats.tasks.table.avg.attempts": "Средние попытки",
|
||||||
|
"challenge.admin.detailed.stats.participants.title": "Активные участники",
|
||||||
|
"challenge.admin.detailed.stats.participants.empty": "Нет активных участников",
|
||||||
|
"challenge.admin.detailed.stats.participants.completed": "Завершено:",
|
||||||
|
"challenge.admin.detailed.stats.participants.attempts": "Попыток:",
|
||||||
|
"challenge.admin.detailed.stats.participants.no.progress": "Нет прогресса по цепочкам",
|
||||||
|
"challenge.admin.detailed.stats.chains.title": "Детальный прогресс по цепочкам",
|
||||||
|
"challenge.admin.detailed.stats.chains.empty": "Нет данных по цепочкам",
|
||||||
|
"challenge.admin.detailed.stats.chains.total.tasks": "Всего заданий:",
|
||||||
|
"challenge.admin.detailed.stats.chains.participant": "Участник",
|
||||||
|
"challenge.admin.detailed.stats.chains.progress": "Прогресс",
|
||||||
|
"challenge.admin.detailed.stats.chains.no.participants": "Нет участников в этой цепочке",
|
||||||
|
"challenge.admin.detailed.stats.status.not.started": "Не начато",
|
||||||
|
"challenge.admin.detailed.stats.status.pending": "Ожидает",
|
||||||
|
"challenge.admin.detailed.stats.status.in.progress": "В процессе",
|
||||||
|
"challenge.admin.detailed.stats.status.needs.revision": "Доработка",
|
||||||
|
"challenge.admin.detailed.stats.status.completed": "Завершено"
|
||||||
}
|
}
|
||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
ChallengeUser,
|
ChallengeUser,
|
||||||
ChallengeSubmission,
|
ChallengeSubmission,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
|
SystemStatsV2,
|
||||||
UserStats,
|
UserStats,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
UpdateTaskRequest,
|
UpdateTaskRequest,
|
||||||
@ -125,6 +126,14 @@ export const api = createApi({
|
|||||||
transformResponse: (response: { body: SystemStats }) => response.body,
|
transformResponse: (response: { body: SystemStats }) => response.body,
|
||||||
providesTags: ['Stats'],
|
providesTags: ['Stats'],
|
||||||
}),
|
}),
|
||||||
|
getSystemStatsV2: builder.query<SystemStatsV2, string | undefined>({
|
||||||
|
query: (chainId) => ({
|
||||||
|
url: '/challenge/stats/v2',
|
||||||
|
params: chainId ? { chainId } : undefined,
|
||||||
|
}),
|
||||||
|
transformResponse: (response: { body: SystemStatsV2 }) => response.body,
|
||||||
|
providesTags: ['Stats'],
|
||||||
|
}),
|
||||||
getUserStats: builder.query<UserStats, string>({
|
getUserStats: builder.query<UserStats, string>({
|
||||||
query: (userId) => `/challenge/user/${userId}/stats`,
|
query: (userId) => `/challenge/user/${userId}/stats`,
|
||||||
transformResponse: (response: { body: UserStats }) => response.body,
|
transformResponse: (response: { body: UserStats }) => response.body,
|
||||||
@ -161,6 +170,7 @@ export const {
|
|||||||
useDeleteChainMutation,
|
useDeleteChainMutation,
|
||||||
useGetUsersQuery,
|
useGetUsersQuery,
|
||||||
useGetSystemStatsQuery,
|
useGetSystemStatsQuery,
|
||||||
|
useGetSystemStatsV2Query,
|
||||||
useGetUserStatsQuery,
|
useGetUserStatsQuery,
|
||||||
useGetUserSubmissionsQuery,
|
useGetUserSubmissionsQuery,
|
||||||
useGetAllSubmissionsQuery,
|
useGetAllSubmissionsQuery,
|
||||||
|
|||||||
@ -12,6 +12,11 @@ export const URLs = {
|
|||||||
// Dashboard
|
// Dashboard
|
||||||
dashboard: makeUrl(''),
|
dashboard: makeUrl(''),
|
||||||
|
|
||||||
|
// Detailed Stats
|
||||||
|
detailedStats: makeUrl('/detailed-stats'),
|
||||||
|
detailedStatsChain: (chainId: string) => makeUrl(`/detailed-stats/${chainId}`),
|
||||||
|
detailedStatsChainPath: makeUrl('/detailed-stats/:chainId'),
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
tasks: makeUrl('/tasks'),
|
tasks: makeUrl('/tasks'),
|
||||||
taskNew: makeUrl('/tasks/new'),
|
taskNew: makeUrl('/tasks/new'),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react'
|
import { Box, Container, Flex, HStack, Button, Text } from '@chakra-ui/react'
|
||||||
import { useAppSelector } from '../__data__/store'
|
import { useAppSelector } from '../__data__/store'
|
||||||
import { URLs } from '../__data__/urls'
|
import { URLs } from '../__data__/urls'
|
||||||
import { keycloak } from '../__data__/kc'
|
import { keycloak } from '../__data__/kc'
|
||||||
@ -34,6 +34,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: t('challenge.admin.layout.nav.dashboard'), path: URLs.dashboard },
|
{ label: t('challenge.admin.layout.nav.dashboard'), path: URLs.dashboard },
|
||||||
|
{ label: t('challenge.admin.layout.nav.detailed.stats'), path: URLs.detailedStats },
|
||||||
{ label: t('challenge.admin.layout.nav.tasks'), path: URLs.tasks },
|
{ label: t('challenge.admin.layout.nav.tasks'), path: URLs.tasks },
|
||||||
{ label: t('challenge.admin.layout.nav.chains'), path: URLs.chains },
|
{ label: t('challenge.admin.layout.nav.chains'), path: URLs.chains },
|
||||||
{ label: t('challenge.admin.layout.nav.users'), path: URLs.users },
|
{ label: t('challenge.admin.layout.nav.users'), path: URLs.users },
|
||||||
@ -106,16 +107,15 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
<Container maxW="container.xl">
|
<Container maxW="container.xl">
|
||||||
<HStack gap={1} py={2}>
|
<HStack gap={1} py={2}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Button
|
<Link key={item.path} to={item.path} style={{ textDecoration: 'none' }}>
|
||||||
key={item.path}
|
<Button
|
||||||
as={Link}
|
size="sm"
|
||||||
to={item.path}
|
variant={isActive(item.path) ? 'solid' : 'ghost'}
|
||||||
size="sm"
|
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
|
||||||
variant={isActive(item.path) ? 'solid' : 'ghost'}
|
>
|
||||||
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
|
{item.label}
|
||||||
>
|
</Button>
|
||||||
{item.label}
|
</Link>
|
||||||
</Button>
|
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'
|
|||||||
|
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { DashboardPage } from './pages/dashboard/DashboardPage'
|
import { DashboardPage } from './pages/dashboard/DashboardPage'
|
||||||
|
import { DetailedStatsPage } from './pages/detailed-stats/DetailedStatsPage'
|
||||||
import { TasksListPage } from './pages/tasks/TasksListPage'
|
import { TasksListPage } from './pages/tasks/TasksListPage'
|
||||||
import { TaskFormPage } from './pages/tasks/TaskFormPage'
|
import { TaskFormPage } from './pages/tasks/TaskFormPage'
|
||||||
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
||||||
@ -30,6 +31,24 @@ export const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Detailed Stats */}
|
||||||
|
<Route
|
||||||
|
path={URLs.detailedStats}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<DetailedStatsPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.detailedStatsChainPath}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<DetailedStatsPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
<Route
|
<Route
|
||||||
path={URLs.tasks}
|
path={URLs.tasks}
|
||||||
|
|||||||
160
src/pages/detailed-stats/ChainDetailedView.tsx
Normal file
160
src/pages/detailed-stats/ChainDetailedView.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Box, Heading, Text, Table, Badge } from '@chakra-ui/react'
|
||||||
|
import type { ChainDetailed, TaskProgressStatus } from '../../types/challenge'
|
||||||
|
|
||||||
|
interface ChainDetailedViewProps {
|
||||||
|
chains: ChainDetailed[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusConfig {
|
||||||
|
label: string
|
||||||
|
color: 'gray' | 'yellow' | 'blue' | 'orange' | 'green'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusConfig = (status: TaskProgressStatus, t: (key: string) => string): StatusConfig => {
|
||||||
|
const configs: Record<TaskProgressStatus, StatusConfig> = {
|
||||||
|
not_started: { label: t('challenge.admin.detailed.stats.status.not.started'), color: 'gray' },
|
||||||
|
pending: { label: t('challenge.admin.detailed.stats.status.pending'), color: 'yellow' },
|
||||||
|
in_progress: { label: t('challenge.admin.detailed.stats.status.in.progress'), color: 'blue' },
|
||||||
|
needs_revision: { label: t('challenge.admin.detailed.stats.status.needs.revision'), color: 'orange' },
|
||||||
|
completed: { label: t('challenge.admin.detailed.stats.status.completed'), color: 'green' },
|
||||||
|
}
|
||||||
|
return configs[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChainDetailedView: React.FC<ChainDetailedViewProps> = ({ chains }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (chains.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.chains.title')}
|
||||||
|
</Heading>
|
||||||
|
<Box color="gray.500" textAlign="center" py={8}>
|
||||||
|
{t('challenge.admin.detailed.stats.chains.empty')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = chains[0] // Теперь всегда одна цепочка из API
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.chains.title')}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Box mb={3}>
|
||||||
|
<Heading size="sm" color="teal.600" mb={1}>
|
||||||
|
{chain.name}
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.chains.total.tasks')} {chain.totalTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{chain.participantProgress.length > 0 ? (
|
||||||
|
<Box overflowX="auto">
|
||||||
|
<Table.Root size="sm" variant="outline">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row bg="gray.50">
|
||||||
|
<Table.ColumnHeader
|
||||||
|
position="sticky"
|
||||||
|
left={0}
|
||||||
|
bg="gray.50"
|
||||||
|
zIndex={1}
|
||||||
|
minW="150px"
|
||||||
|
borderRight="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.chains.participant')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
{chain.tasks.map((task) => (
|
||||||
|
<Table.ColumnHeader
|
||||||
|
key={task.taskId}
|
||||||
|
textAlign="center"
|
||||||
|
minW="120px"
|
||||||
|
maxW="200px"
|
||||||
|
>
|
||||||
|
<Text fontSize="xs" lineClamp={2} title={task.title}>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
))}
|
||||||
|
<Table.ColumnHeader
|
||||||
|
textAlign="center"
|
||||||
|
minW="100px"
|
||||||
|
borderLeft="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.chains.progress')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{chain.participantProgress.map((participant) => (
|
||||||
|
<Table.Row key={participant.userId}>
|
||||||
|
<Table.Cell
|
||||||
|
position="sticky"
|
||||||
|
left={0}
|
||||||
|
bg="white"
|
||||||
|
zIndex={1}
|
||||||
|
fontWeight="medium"
|
||||||
|
borderRight="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
{participant.nickname}
|
||||||
|
</Table.Cell>
|
||||||
|
{participant.taskProgress.map((taskProg) => {
|
||||||
|
const config = getStatusConfig(taskProg.status, t)
|
||||||
|
return (
|
||||||
|
<Table.Cell key={taskProg.taskId} textAlign="center">
|
||||||
|
<Badge
|
||||||
|
colorPalette={config.color}
|
||||||
|
size="sm"
|
||||||
|
title={taskProg.taskTitle}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Table.Cell
|
||||||
|
textAlign="center"
|
||||||
|
borderLeft="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
colorPalette={
|
||||||
|
participant.progressPercent >= 80
|
||||||
|
? 'green'
|
||||||
|
: participant.progressPercent >= 50
|
||||||
|
? 'yellow'
|
||||||
|
: 'red'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{participant.progressPercent}%
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="xs" color="gray.600" mt={1}>
|
||||||
|
{participant.completedCount}/{chain.totalTasks}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box color="gray.500" textAlign="center" py={4} bg="gray.50" borderRadius="md">
|
||||||
|
{t('challenge.admin.detailed.stats.chains.no.participants')}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
240
src/pages/detailed-stats/DetailedStatsPage.tsx
Normal file
240
src/pages/detailed-stats/DetailedStatsPage.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Box, Heading, VStack, Text, HStack, Badge, SimpleGrid } from '@chakra-ui/react'
|
||||||
|
import { useGetChainsQuery, useGetSystemStatsV2Query } from '../../__data__/api/api'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { TasksStatisticsTable } from './TasksStatisticsTable'
|
||||||
|
import { ParticipantsProgress } from './ParticipantsProgress'
|
||||||
|
import { ChainDetailedView } from './ChainDetailedView'
|
||||||
|
|
||||||
|
export const DetailedStatsPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { chainId } = useParams<{ chainId?: string }>()
|
||||||
|
|
||||||
|
// Получаем список цепочек
|
||||||
|
const { data: chains, isLoading: isChainsLoading, error: chainsError } = useGetChainsQuery()
|
||||||
|
|
||||||
|
// Получаем детальную статистику по выбранной цепочке (только если chainId есть)
|
||||||
|
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch } = useGetSystemStatsV2Query(
|
||||||
|
chainId,
|
||||||
|
{
|
||||||
|
pollingInterval: 5000, // Обновление каждые 5 секунд для реального времени
|
||||||
|
skip: !chainId, // Не делаем запрос пока не выбрана цепочка
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoading = isChainsLoading || (chainId && isStatsLoading)
|
||||||
|
const error = chainsError || statsError
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message={t('challenge.admin.detailed.stats.loading')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
message={t('challenge.admin.detailed.stats.load.error')}
|
||||||
|
onRetry={refetch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если chainId не указан - показываем карточки для выбора
|
||||||
|
if (!chainId) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box mb={6}>
|
||||||
|
<Heading mb={2}>{t('challenge.admin.detailed.stats.title')}</Heading>
|
||||||
|
<Text color="gray.600" fontSize="sm">
|
||||||
|
{t('challenge.admin.detailed.stats.select.chain')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{chains && chains.length > 0 ? (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||||
|
{chains.map((chain) => (
|
||||||
|
<Link key={chain.id} to={URLs.detailedStatsChain(chain.id)} style={{ textDecoration: 'none' }}>
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
bg="white"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="sm"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
_hover={{
|
||||||
|
boxShadow: 'md',
|
||||||
|
borderColor: 'teal.400',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
cursor="pointer"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<VStack align="start" gap={3}>
|
||||||
|
<Heading size="md" color="teal.600">
|
||||||
|
{chain.name}
|
||||||
|
</Heading>
|
||||||
|
<HStack>
|
||||||
|
<Badge colorPalette="teal" size="lg">
|
||||||
|
{chain.tasks.length} {t('challenge.admin.detailed.stats.chain.card.tasks')}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||||
|
{t('challenge.admin.detailed.stats.chain.card.click')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Box color="gray.500" textAlign="center" py={8}>
|
||||||
|
{t('challenge.admin.detailed.stats.no.chains')}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если chainId указан но данных еще нет - ждем загрузки
|
||||||
|
if (!stats) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptanceRate = stats.submissions.total > 0
|
||||||
|
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const selectedChain = chains?.find(c => c.id === chainId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box mb={6}>
|
||||||
|
<HStack justify="space-between" align="start" mb={2}>
|
||||||
|
<Box>
|
||||||
|
<HStack gap={2} mb={2}>
|
||||||
|
<Link to={URLs.detailedStats} style={{ textDecoration: 'none', color: '#319795' }}>
|
||||||
|
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }}>
|
||||||
|
← {t('challenge.admin.detailed.stats.back.to.chains')}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</HStack>
|
||||||
|
<Heading mb={2}>
|
||||||
|
{selectedChain?.name || t('challenge.admin.detailed.stats.title')}
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600" fontSize="sm">
|
||||||
|
{t('challenge.admin.detailed.stats.auto.refresh')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quick Stats Overview */}
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
p={6}
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="sm"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
mb={6}
|
||||||
|
>
|
||||||
|
<Heading size="sm" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.overview.title')}
|
||||||
|
</Heading>
|
||||||
|
<HStack wrap="wrap" gap={6}>
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.users')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||||
|
{stats.users}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.tasks')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
|
||||||
|
{stats.tasks}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.chains')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
|
||||||
|
{stats.chains}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.total.attempts')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||||
|
{stats.submissions.total}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.successful')}
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||||
|
{stats.submissions.accepted}
|
||||||
|
</Text>
|
||||||
|
<Badge colorPalette="green" size="lg">
|
||||||
|
{acceptanceRate}%
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.in.progress.pending')}
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Badge colorPalette="blue" size="lg">
|
||||||
|
{stats.submissions.inProgress}
|
||||||
|
</Badge>
|
||||||
|
<Badge colorPalette="yellow" size="lg">
|
||||||
|
{stats.submissions.pending}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.overview.avg.check.time')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
|
||||||
|
{(stats.averageCheckTimeMs / 1000).toFixed(1)}с
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content - Three Sections */}
|
||||||
|
{stats && (
|
||||||
|
<VStack align="stretch" gap={6}>
|
||||||
|
{/* 1. Tasks Statistics Table */}
|
||||||
|
<TasksStatisticsTable tasks={stats.tasksTable} />
|
||||||
|
|
||||||
|
{/* 2. Active Participants Progress */}
|
||||||
|
<ParticipantsProgress participants={stats.activeParticipants} />
|
||||||
|
|
||||||
|
{/* 3. Chain Detailed View */}
|
||||||
|
<ChainDetailedView chains={stats.chainsDetailed} />
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
110
src/pages/detailed-stats/ParticipantsProgress.tsx
Normal file
110
src/pages/detailed-stats/ParticipantsProgress.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Box, Heading, Grid, Text, VStack, HStack, Progress } from '@chakra-ui/react'
|
||||||
|
import type { ActiveParticipant } from '../../types/challenge'
|
||||||
|
|
||||||
|
interface ParticipantsProgressProps {
|
||||||
|
participants: ActiveParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ participants }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const getProgressColor = (percent: number) => {
|
||||||
|
if (percent >= 80) return 'green'
|
||||||
|
if (percent >= 50) return 'yellow'
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.participants.title')}
|
||||||
|
</Heading>
|
||||||
|
<Box color="gray.500" textAlign="center" py={8}>
|
||||||
|
{t('challenge.admin.detailed.stats.participants.empty')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.participants.title')}
|
||||||
|
</Heading>
|
||||||
|
<Grid templateColumns="repeat(auto-fill, minmax(350px, 1fr))" gap={4}>
|
||||||
|
{participants.map((participant) => (
|
||||||
|
<Box
|
||||||
|
key={participant.userId}
|
||||||
|
p={4}
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
bg="gray.50"
|
||||||
|
_hover={{ borderColor: 'teal.300', boxShadow: 'md' }}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<VStack align="stretch" gap={3}>
|
||||||
|
{/* Participant Header */}
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color="teal.700">
|
||||||
|
{participant.nickname}
|
||||||
|
</Text>
|
||||||
|
<HStack gap={4} mt={1}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.participants.completed')} <Text as="span" fontWeight="bold" color="green.600">
|
||||||
|
{participant.completedTasks}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.detailed.stats.participants.attempts')} <Text as="span" fontWeight="bold" color="blue.600">
|
||||||
|
{participant.totalSubmissions}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Chain Progress */}
|
||||||
|
{participant.chainProgress.length > 0 ? (
|
||||||
|
<VStack align="stretch" gap={3} mt={2}>
|
||||||
|
{participant.chainProgress.map((chain) => (
|
||||||
|
<Box key={chain.chainId}>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" fontWeight="medium" color="gray.700">
|
||||||
|
{chain.chainName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{chain.completedTasks}/{chain.totalTasks}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Progress.Root
|
||||||
|
value={chain.progressPercent}
|
||||||
|
colorPalette={getProgressColor(chain.progressPercent)}
|
||||||
|
size="sm"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
<Text fontSize="xs" color="gray.500" mt={1} textAlign="right">
|
||||||
|
{chain.progressPercent}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||||||
|
{t('challenge.admin.detailed.stats.participants.no.progress')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
148
src/pages/detailed-stats/TasksStatisticsTable.tsx
Normal file
148
src/pages/detailed-stats/TasksStatisticsTable.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Box, Heading, Table, Badge } from '@chakra-ui/react'
|
||||||
|
import type { TaskTableItem } from '../../types/challenge'
|
||||||
|
|
||||||
|
interface TasksStatisticsTableProps {
|
||||||
|
tasks: TaskTableItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = keyof TaskTableItem | null
|
||||||
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export const TasksStatisticsTable: React.FC<TasksStatisticsTableProps> = ({ tasks }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('successRate')
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||||
|
|
||||||
|
const handleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDirection('desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTasks = useMemo(() => {
|
||||||
|
if (!sortKey) return tasks
|
||||||
|
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
const aVal = a[sortKey]
|
||||||
|
const bVal = b[sortKey]
|
||||||
|
|
||||||
|
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||||
|
return sortDirection === 'asc'
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}, [tasks, sortKey, sortDirection])
|
||||||
|
|
||||||
|
const getSuccessRateColor = (rate: number) => {
|
||||||
|
if (rate >= 80) return 'green'
|
||||||
|
if (rate >= 50) return 'yellow'
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.title')}
|
||||||
|
</Heading>
|
||||||
|
<Box color="gray.500" textAlign="center" py={8}>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.empty')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.title')}
|
||||||
|
</Heading>
|
||||||
|
<Box overflowX="auto">
|
||||||
|
<Table.Root size="sm" variant="line">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.task.name')} {sortKey === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('totalAttempts')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.attempts')} {sortKey === 'totalAttempts' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('uniqueUsers')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.users')} {sortKey === 'uniqueUsers' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('acceptedCount')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.completed')} {sortKey === 'acceptedCount' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('successRate')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.success.rate')} {sortKey === 'successRate' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => handleSort('averageAttemptsToSuccess')}
|
||||||
|
_hover={{ bg: 'gray.50' }}
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{t('challenge.admin.detailed.stats.tasks.table.avg.attempts')} {sortKey === 'averageAttemptsToSuccess' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{sortedTasks.map((task) => (
|
||||||
|
<Table.Row key={task.taskId}>
|
||||||
|
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">{task.totalAttempts}</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">{task.uniqueUsers}</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">{task.acceptedCount}</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<Badge colorPalette={getSuccessRateColor(task.successRate)}>
|
||||||
|
{task.successRate}%
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
{task.averageAttemptsToSuccess.toFixed(1)}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ export interface SystemStats {
|
|||||||
|
|
||||||
// API Request/Response types
|
// API Request/Response types
|
||||||
export interface APIResponse<T> {
|
export interface APIResponse<T> {
|
||||||
error: any
|
error: unknown
|
||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,3 +139,87 @@ export interface UpdateChainRequest {
|
|||||||
taskIds?: string[]
|
taskIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Stats v2 Types ==========
|
||||||
|
|
||||||
|
export type TaskProgressStatus = 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
|
||||||
|
|
||||||
|
export interface TaskTableItem {
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
totalAttempts: number
|
||||||
|
uniqueUsers: number
|
||||||
|
acceptedCount: number
|
||||||
|
successRate: number
|
||||||
|
averageAttemptsToSuccess: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainProgress {
|
||||||
|
chainId: string
|
||||||
|
chainName: string
|
||||||
|
totalTasks: number
|
||||||
|
completedTasks: number
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveParticipant {
|
||||||
|
userId: string
|
||||||
|
nickname: string
|
||||||
|
totalSubmissions: number
|
||||||
|
completedTasks: number
|
||||||
|
chainProgress: ChainProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskProgress {
|
||||||
|
taskId: string
|
||||||
|
taskTitle: string
|
||||||
|
status: TaskProgressStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantProgress {
|
||||||
|
userId: string
|
||||||
|
nickname: string
|
||||||
|
taskProgress: TaskProgress[]
|
||||||
|
completedCount: number
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainTask {
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainDetailed {
|
||||||
|
chainId: string
|
||||||
|
name: string
|
||||||
|
totalTasks: number
|
||||||
|
tasks: ChainTask[]
|
||||||
|
participantProgress: ParticipantProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatsV2 {
|
||||||
|
// Базовая статистика из v1
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Новые данные v2
|
||||||
|
tasksTable: TaskTableItem[]
|
||||||
|
activeParticipants: ActiveParticipant[]
|
||||||
|
chainsDetailed: ChainDetailed[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,8 @@ stubs/api/
|
|||||||
│ ├── chains.json # Цепочки (3 шт.)
|
│ ├── chains.json # Цепочки (3 шт.)
|
||||||
│ ├── users.json # Пользователи (8 шт.)
|
│ ├── users.json # Пользователи (8 шт.)
|
||||||
│ ├── submissions.json # Попытки (8 шт.)
|
│ ├── submissions.json # Попытки (8 шт.)
|
||||||
│ └── stats.json # Системная статистика
|
│ ├── stats.json # Системная статистика (v1)
|
||||||
|
│ └── stats-v2.json # Детальная статистика (v2, 20 заданий)
|
||||||
├── index.js # API роуты
|
├── index.js # API роуты
|
||||||
└── README.md # Эта документация
|
└── README.md # Эта документация
|
||||||
```
|
```
|
||||||
@ -36,7 +37,8 @@ stubs/api/
|
|||||||
- `GET /api/challenge/users` - список всех пользователей
|
- `GET /api/challenge/users` - список всех пользователей
|
||||||
|
|
||||||
### Statistics (Статистика)
|
### Statistics (Статистика)
|
||||||
- `GET /api/challenge/stats` - общая системная статистика
|
- `GET /api/challenge/stats` - общая системная статистика (v1)
|
||||||
|
- `GET /api/challenge/stats/v2` - детальная статистика с таблицами и прогрессом (v2)
|
||||||
- `GET /api/challenge/user/:userId/stats` - статистика пользователя (генерируется динамически)
|
- `GET /api/challenge/user/:userId/stats` - статистика пользователя (генерируется динамически)
|
||||||
|
|
||||||
### Submissions (Попытки)
|
### Submissions (Попытки)
|
||||||
@ -150,6 +152,17 @@ GET /api/challenge/user/user001/stats
|
|||||||
|
|
||||||
Ответ будет содержать динамически вычисленную статистику на основе всех попыток пользователя.
|
Ответ будет содержать динамически вычисленную статистику на основе всех попыток пользователя.
|
||||||
|
|
||||||
|
### Получить детальную статистику (v2)
|
||||||
|
```bash
|
||||||
|
GET /api/challenge/stats/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ будет содержать:
|
||||||
|
- Базовую статистику (users, tasks, chains, submissions, queue)
|
||||||
|
- Таблицу заданий с детальной статистикой (20 заданий с попытками, успешностью, средними показателями)
|
||||||
|
- 6 активных участников с прогрессом по цепочкам
|
||||||
|
- Детальную матрицу прогресса по каждой из 2 цепочек (Backend разработка - 10 заданий, Frontend разработка - 10 заданий)
|
||||||
|
|
||||||
## ⚙️ Настройка задержки
|
## ⚙️ Настройка задержки
|
||||||
|
|
||||||
По умолчанию все запросы имеют задержку 300ms для имитации сетевых запросов. Изменить можно в `index.js`:
|
По умолчанию все запросы имеют задержку 300ms для имитации сетевых запросов. Изменить можно в `index.js`:
|
||||||
|
|||||||
592
stubs/api/data/stats-v2.json
Normal file
592
stubs/api/data/stats-v2.json
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
{
|
||||||
|
"users": 8,
|
||||||
|
"tasks": 20,
|
||||||
|
"chains": 2,
|
||||||
|
"submissions": {
|
||||||
|
"total": 95,
|
||||||
|
"accepted": 38,
|
||||||
|
"rejected": 42,
|
||||||
|
"pending": 8,
|
||||||
|
"inProgress": 7
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 2143,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 15,
|
||||||
|
"waiting": 8,
|
||||||
|
"inProgress": 7,
|
||||||
|
"maxConcurrency": 10,
|
||||||
|
"currentlyProcessing": 7
|
||||||
|
},
|
||||||
|
"tasksTable": [
|
||||||
|
{
|
||||||
|
"taskId": "task_1",
|
||||||
|
"title": "Создание REST API",
|
||||||
|
"totalAttempts": 12,
|
||||||
|
"uniqueUsers": 6,
|
||||||
|
"acceptedCount": 5,
|
||||||
|
"successRate": 83,
|
||||||
|
"averageAttemptsToSuccess": 2.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_2",
|
||||||
|
"title": "Работа с базой данных MongoDB",
|
||||||
|
"totalAttempts": 10,
|
||||||
|
"uniqueUsers": 5,
|
||||||
|
"acceptedCount": 3,
|
||||||
|
"successRate": 60,
|
||||||
|
"averageAttemptsToSuccess": 3.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_3",
|
||||||
|
"title": "JWT аутентификация",
|
||||||
|
"totalAttempts": 8,
|
||||||
|
"uniqueUsers": 4,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 50,
|
||||||
|
"averageAttemptsToSuccess": 4.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_4",
|
||||||
|
"title": "Middleware для Express",
|
||||||
|
"totalAttempts": 6,
|
||||||
|
"uniqueUsers": 4,
|
||||||
|
"acceptedCount": 3,
|
||||||
|
"successRate": 75,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_5",
|
||||||
|
"title": "WebSocket сервер",
|
||||||
|
"totalAttempts": 5,
|
||||||
|
"uniqueUsers": 3,
|
||||||
|
"acceptedCount": 1,
|
||||||
|
"successRate": 33,
|
||||||
|
"averageAttemptsToSuccess": 5.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_6",
|
||||||
|
"title": "Кэширование с Redis",
|
||||||
|
"totalAttempts": 7,
|
||||||
|
"uniqueUsers": 4,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 57,
|
||||||
|
"averageAttemptsToSuccess": 3.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_7",
|
||||||
|
"title": "GraphQL Schema",
|
||||||
|
"totalAttempts": 4,
|
||||||
|
"uniqueUsers": 3,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 67,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_8",
|
||||||
|
"title": "Docker контейнеризация",
|
||||||
|
"totalAttempts": 3,
|
||||||
|
"uniqueUsers": 2,
|
||||||
|
"acceptedCount": 1,
|
||||||
|
"successRate": 50,
|
||||||
|
"averageAttemptsToSuccess": 3.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_9",
|
||||||
|
"title": "CI/CD Pipeline",
|
||||||
|
"totalAttempts": 2,
|
||||||
|
"uniqueUsers": 2,
|
||||||
|
"acceptedCount": 1,
|
||||||
|
"successRate": 50,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_10",
|
||||||
|
"title": "Микросервисная архитектура",
|
||||||
|
"totalAttempts": 2,
|
||||||
|
"uniqueUsers": 1,
|
||||||
|
"acceptedCount": 0,
|
||||||
|
"successRate": 0,
|
||||||
|
"averageAttemptsToSuccess": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_11",
|
||||||
|
"title": "React компоненты",
|
||||||
|
"totalAttempts": 11,
|
||||||
|
"uniqueUsers": 6,
|
||||||
|
"acceptedCount": 6,
|
||||||
|
"successRate": 100,
|
||||||
|
"averageAttemptsToSuccess": 1.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_12",
|
||||||
|
"title": "React Hooks",
|
||||||
|
"totalAttempts": 9,
|
||||||
|
"uniqueUsers": 5,
|
||||||
|
"acceptedCount": 4,
|
||||||
|
"successRate": 80,
|
||||||
|
"averageAttemptsToSuccess": 2.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_13",
|
||||||
|
"title": "Redux State Management",
|
||||||
|
"totalAttempts": 7,
|
||||||
|
"uniqueUsers": 4,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 57,
|
||||||
|
"averageAttemptsToSuccess": 3.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_14",
|
||||||
|
"title": "React Router",
|
||||||
|
"totalAttempts": 6,
|
||||||
|
"uniqueUsers": 4,
|
||||||
|
"acceptedCount": 3,
|
||||||
|
"successRate": 75,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_15",
|
||||||
|
"title": "Form валидация",
|
||||||
|
"totalAttempts": 5,
|
||||||
|
"uniqueUsers": 3,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 67,
|
||||||
|
"averageAttemptsToSuccess": 2.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_16",
|
||||||
|
"title": "API интеграция",
|
||||||
|
"totalAttempts": 8,
|
||||||
|
"uniqueUsers": 5,
|
||||||
|
"acceptedCount": 3,
|
||||||
|
"successRate": 60,
|
||||||
|
"averageAttemptsToSuccess": 2.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_17",
|
||||||
|
"title": "CSS-in-JS стилизация",
|
||||||
|
"totalAttempts": 4,
|
||||||
|
"uniqueUsers": 3,
|
||||||
|
"acceptedCount": 2,
|
||||||
|
"successRate": 67,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_18",
|
||||||
|
"title": "Оптимизация производительности",
|
||||||
|
"totalAttempts": 3,
|
||||||
|
"uniqueUsers": 2,
|
||||||
|
"acceptedCount": 1,
|
||||||
|
"successRate": 50,
|
||||||
|
"averageAttemptsToSuccess": 3.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_19",
|
||||||
|
"title": "Unit тесты с Jest",
|
||||||
|
"totalAttempts": 2,
|
||||||
|
"uniqueUsers": 2,
|
||||||
|
"acceptedCount": 1,
|
||||||
|
"successRate": 50,
|
||||||
|
"averageAttemptsToSuccess": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "task_20",
|
||||||
|
"title": "E2E тесты с Playwright",
|
||||||
|
"totalAttempts": 1,
|
||||||
|
"uniqueUsers": 1,
|
||||||
|
"acceptedCount": 0,
|
||||||
|
"successRate": 0,
|
||||||
|
"averageAttemptsToSuccess": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeParticipants": [
|
||||||
|
{
|
||||||
|
"userId": "user_1",
|
||||||
|
"nickname": "alex_dev",
|
||||||
|
"totalSubmissions": 18,
|
||||||
|
"completedTasks": 12,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_2",
|
||||||
|
"nickname": "maria_coder",
|
||||||
|
"totalSubmissions": 15,
|
||||||
|
"completedTasks": 9,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 4,
|
||||||
|
"progressPercent": 40
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_3",
|
||||||
|
"nickname": "ivan_programmer",
|
||||||
|
"totalSubmissions": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 3,
|
||||||
|
"progressPercent": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 2,
|
||||||
|
"progressPercent": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_4",
|
||||||
|
"nickname": "kate_fullstack",
|
||||||
|
"totalSubmissions": 22,
|
||||||
|
"completedTasks": 15,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 8,
|
||||||
|
"progressPercent": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 7,
|
||||||
|
"progressPercent": 70
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_5",
|
||||||
|
"nickname": "dmitry_backend",
|
||||||
|
"totalSubmissions": 12,
|
||||||
|
"completedTasks": 6,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 1,
|
||||||
|
"progressPercent": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_6",
|
||||||
|
"nickname": "anna_react",
|
||||||
|
"totalSubmissions": 14,
|
||||||
|
"completedTasks": 7,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"chainName": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 1,
|
||||||
|
"progressPercent": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"chainName": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainsDetailed": [
|
||||||
|
{
|
||||||
|
"chainId": "chain_1",
|
||||||
|
"name": "Backend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"tasks": [
|
||||||
|
{ "taskId": "task_1", "title": "Создание REST API", "description": "Создайте REST API с Express.js" },
|
||||||
|
{ "taskId": "task_2", "title": "Работа с базой данных MongoDB", "description": "Интегрируйте MongoDB" },
|
||||||
|
{ "taskId": "task_3", "title": "JWT аутентификация", "description": "Реализуйте JWT-аутентификацию" },
|
||||||
|
{ "taskId": "task_4", "title": "Middleware для Express", "description": "Создайте кастомные middleware" },
|
||||||
|
{ "taskId": "task_5", "title": "WebSocket сервер", "description": "Реализуйте WebSocket для real-time" },
|
||||||
|
{ "taskId": "task_6", "title": "Кэширование с Redis", "description": "Внедрите Redis для кэширования" },
|
||||||
|
{ "taskId": "task_7", "title": "GraphQL Schema", "description": "Создайте GraphQL API" },
|
||||||
|
{ "taskId": "task_8", "title": "Docker контейнеризация", "description": "Контейнеризируйте приложение" },
|
||||||
|
{ "taskId": "task_9", "title": "CI/CD Pipeline", "description": "Настройте CI/CD" },
|
||||||
|
{ "taskId": "task_10", "title": "Микросервисная архитектура", "description": "Разбейте на микросервисы" }
|
||||||
|
],
|
||||||
|
"participantProgress": [
|
||||||
|
{
|
||||||
|
"userId": "user_1",
|
||||||
|
"nickname": "alex_dev",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "completed" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "completed" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "completed" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "completed" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "not_started" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_2",
|
||||||
|
"nickname": "maria_coder",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "completed" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "completed" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "completed" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "needs_revision" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "pending" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "not_started" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_3",
|
||||||
|
"nickname": "ivan_programmer",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "completed" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "needs_revision" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "not_started" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "not_started" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "not_started" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "not_started" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 3,
|
||||||
|
"progressPercent": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_4",
|
||||||
|
"nickname": "kate_fullstack",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "completed" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "completed" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "completed" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "completed" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "completed" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "completed" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 8,
|
||||||
|
"progressPercent": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_5",
|
||||||
|
"nickname": "dmitry_backend",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "completed" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "completed" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "completed" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "not_started" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "not_started" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_6",
|
||||||
|
"nickname": "anna_react",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||||
|
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
|
||||||
|
{ "taskId": "task_3", "taskTitle": "JWT аутентификация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_4", "taskTitle": "Middleware для Express", "status": "not_started" },
|
||||||
|
{ "taskId": "task_5", "taskTitle": "WebSocket сервер", "status": "not_started" },
|
||||||
|
{ "taskId": "task_6", "taskTitle": "Кэширование с Redis", "status": "not_started" },
|
||||||
|
{ "taskId": "task_7", "taskTitle": "GraphQL Schema", "status": "not_started" },
|
||||||
|
{ "taskId": "task_8", "taskTitle": "Docker контейнеризация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_9", "taskTitle": "CI/CD Pipeline", "status": "not_started" },
|
||||||
|
{ "taskId": "task_10", "taskTitle": "Микросервисная архитектура", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 1,
|
||||||
|
"progressPercent": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chainId": "chain_2",
|
||||||
|
"name": "Frontend разработка",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"tasks": [
|
||||||
|
{ "taskId": "task_11", "title": "React компоненты", "description": "Создайте переиспользуемые компоненты" },
|
||||||
|
{ "taskId": "task_12", "title": "React Hooks", "description": "Используйте хуки" },
|
||||||
|
{ "taskId": "task_13", "title": "Redux State Management", "description": "Управление состоянием" },
|
||||||
|
{ "taskId": "task_14", "title": "React Router", "description": "Маршрутизация" },
|
||||||
|
{ "taskId": "task_15", "title": "Form валидация", "description": "Валидация форм" },
|
||||||
|
{ "taskId": "task_16", "title": "API интеграция", "description": "Интеграция с API" },
|
||||||
|
{ "taskId": "task_17", "title": "CSS-in-JS стилизация", "description": "Стилизация" },
|
||||||
|
{ "taskId": "task_18", "title": "Оптимизация производительности", "description": "Оптимизация React" },
|
||||||
|
{ "taskId": "task_19", "title": "Unit тесты с Jest", "description": "Юнит тестирование" },
|
||||||
|
{ "taskId": "task_20", "title": "E2E тесты с Playwright", "description": "End-to-end тестирование" }
|
||||||
|
],
|
||||||
|
"participantProgress": [
|
||||||
|
{
|
||||||
|
"userId": "user_1",
|
||||||
|
"nickname": "alex_dev",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "completed" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "completed" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "completed" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "completed" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "not_started" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_2",
|
||||||
|
"nickname": "maria_coder",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "completed" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "completed" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "needs_revision" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "pending" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "not_started" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 4,
|
||||||
|
"progressPercent": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_3",
|
||||||
|
"nickname": "ivan_programmer",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "not_started" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "not_started" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "not_started" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 2,
|
||||||
|
"progressPercent": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_4",
|
||||||
|
"nickname": "kate_fullstack",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "completed" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "completed" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "completed" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "completed" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "completed" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 7,
|
||||||
|
"progressPercent": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_5",
|
||||||
|
"nickname": "dmitry_backend",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "not_started" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "not_started" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "not_started" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "not_started" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "not_started" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 1,
|
||||||
|
"progressPercent": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"userId": "user_6",
|
||||||
|
"nickname": "anna_react",
|
||||||
|
"taskProgress": [
|
||||||
|
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||||
|
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||||
|
{ "taskId": "task_13", "taskTitle": "Redux State Management", "status": "completed" },
|
||||||
|
{ "taskId": "task_14", "taskTitle": "React Router", "status": "completed" },
|
||||||
|
{ "taskId": "task_15", "taskTitle": "Form валидация", "status": "completed" },
|
||||||
|
{ "taskId": "task_16", "taskTitle": "API интеграция", "status": "completed" },
|
||||||
|
{ "taskId": "task_17", "taskTitle": "CSS-in-JS стилизация", "status": "in_progress" },
|
||||||
|
{ "taskId": "task_18", "taskTitle": "Оптимизация производительности", "status": "not_started" },
|
||||||
|
{ "taskId": "task_19", "taskTitle": "Unit тесты с Jest", "status": "not_started" },
|
||||||
|
{ "taskId": "task_20", "taskTitle": "E2E тесты с Playwright", "status": "not_started" }
|
||||||
|
],
|
||||||
|
"completedCount": 6,
|
||||||
|
"progressPercent": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"users": 8,
|
"users": 8,
|
||||||
"tasks": 5,
|
"tasks": 20,
|
||||||
"chains": 3,
|
"chains": 2,
|
||||||
"submissions": {
|
"submissions": {
|
||||||
"total": 8,
|
"total": 95,
|
||||||
"accepted": 5,
|
"accepted": 38,
|
||||||
"rejected": 3,
|
"rejected": 42,
|
||||||
"pending": 0,
|
"pending": 8,
|
||||||
"inProgress": 0
|
"inProgress": 7
|
||||||
},
|
},
|
||||||
"averageCheckTimeMs": 3275,
|
"averageCheckTimeMs": 2143,
|
||||||
"queue": {
|
"queue": {
|
||||||
"queueLength": 0,
|
"queueLength": 15,
|
||||||
"waiting": 0,
|
"waiting": 8,
|
||||||
"inProgress": 0,
|
"inProgress": 7,
|
||||||
"maxConcurrency": 5,
|
"maxConcurrency": 10,
|
||||||
"currentlyProcessing": 0
|
"currentlyProcessing": 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ let chainsCache = null;
|
|||||||
let usersCache = null;
|
let usersCache = null;
|
||||||
let submissionsCache = null;
|
let submissionsCache = null;
|
||||||
let statsCache = null;
|
let statsCache = null;
|
||||||
|
let statsV2Cache = null;
|
||||||
|
|
||||||
const getTasks = () => {
|
const getTasks = () => {
|
||||||
if (!tasksCache) tasksCache = loadJSON('tasks.json');
|
if (!tasksCache) tasksCache = loadJSON('tasks.json');
|
||||||
@ -55,6 +56,11 @@ const getStats = () => {
|
|||||||
return statsCache;
|
return statsCache;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatsV2 = () => {
|
||||||
|
if (!statsV2Cache) statsV2Cache = loadJSON('stats-v2.json');
|
||||||
|
return statsV2Cache;
|
||||||
|
};
|
||||||
|
|
||||||
router.use(timer());
|
router.use(timer());
|
||||||
|
|
||||||
// ============= TASKS =============
|
// ============= TASKS =============
|
||||||
@ -282,6 +288,46 @@ router.get('/challenge/stats', (req, res) => {
|
|||||||
respond(res, stats);
|
respond(res, stats);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/stats/v2
|
||||||
|
router.get('/challenge/stats/v2', (req, res) => {
|
||||||
|
const statsV2 = getStatsV2();
|
||||||
|
const chainId = req.query.chainId;
|
||||||
|
|
||||||
|
// Если chainId не передан, возвращаем все данные
|
||||||
|
if (!chainId) {
|
||||||
|
respond(res, statsV2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем данные по выбранной цепочке
|
||||||
|
const filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId);
|
||||||
|
|
||||||
|
if (!filteredChain) {
|
||||||
|
return respondError(res, 'Chain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем tasksTable - только задания из этой цепочки
|
||||||
|
const chainTaskIds = new Set(filteredChain.tasks.map(t => t.taskId));
|
||||||
|
const filteredTasksTable = statsV2.tasksTable.filter(t => chainTaskIds.has(t.taskId));
|
||||||
|
|
||||||
|
// Фильтруем activeParticipants - только участники с попытками в этой цепочке
|
||||||
|
const participantIds = new Set(filteredChain.participantProgress.map(p => p.userId));
|
||||||
|
const filteredParticipants = statsV2.activeParticipants
|
||||||
|
.filter(p => participantIds.has(p.userId))
|
||||||
|
.map(p => ({
|
||||||
|
...p,
|
||||||
|
chainProgress: p.chainProgress.filter(cp => cp.chainId === chainId)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Возвращаем отфильтрованные данные
|
||||||
|
respond(res, {
|
||||||
|
...statsV2,
|
||||||
|
tasksTable: filteredTasksTable,
|
||||||
|
activeParticipants: filteredParticipants,
|
||||||
|
chainsDetailed: [filteredChain]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/challenge/user/:userId/stats
|
// GET /api/challenge/user/:userId/stats
|
||||||
router.get('/challenge/user/:userId/stats', (req, res) => {
|
router.get('/challenge/user/:userId/stats', (req, res) => {
|
||||||
const users = getUsers();
|
const users = getUsers();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user