diff --git a/docs/stats-v2-api.md b/docs/stats-v2-api.md new file mode 100644 index 0000000..03e7b54 --- /dev/null +++ b/docs/stats-v2-api.md @@ -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 ( + + + + + + + + + + + + + {tasksTable.map(task => ( + + + + + + + + + ))} + +
Название заданияПопытокУникальных пользователейУспешно завершено% успехаСреднее попыток до успеха
{task.title}{task.totalAttempts}{task.uniqueUsers}{task.acceptedCount}{task.successRate}%{task.averageAttemptsToSuccess.toFixed(1)}
+ ); +} +``` + +### 2. Прогресс-бары участников + +Используйте `activeParticipants` для отображения прогресса каждого участника: + +```tsx +// React пример +function ParticipantProgress({ activeParticipants }) { + return ( +
+ {activeParticipants.map(participant => ( +
+

{participant.nickname}

+

Завершено заданий: {participant.completedTasks}

+

Всего попыток: {participant.totalSubmissions}

+ +
+ {participant.chainProgress.map(chain => ( +
+
+ {chain.chainName} + {chain.completedTasks}/{chain.totalTasks} +
+
+
+
+ {chain.progressPercent}% +
+ ))} +
+
+ ))} +
+ ); +} +``` + +### 3. Детальный прогресс по цепочкам + +Используйте `chainsDetailed` для отображения детального прогресса всех участников в рамках каждой цепочки: + +```tsx +// React пример +function ChainDetailedView({ chainsDetailed }) { + return ( +
+ {chainsDetailed.map(chain => ( +
+

{chain.name}

+

Заданий в цепочке: {chain.totalTasks}

+ + {/* Список заданий */} +
+

Задания:

+ {chain.tasks.map(task => ( +
+

{task.title}

+

{task.description}

+
+ ))} +
+ + {/* Прогресс участников */} +
+

Прогресс участников:

+ + + + + {chain.tasks.map(task => ( + + ))} + + + + + {chain.participantProgress.map(participant => ( + + + {participant.taskProgress.map(taskProg => ( + + ))} + + + ))} + +
Участник{task.title}Прогресс
{participant.nickname} + + {participant.progressPercent}%
+
+
+ ))} +
+ ); +} + +// Компонент для отображения статуса +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 ( + + {config.label} + + ); +} +``` + +## Рекомендации по использованию + +### Производительность + +- Эндпоинт выполняет множество агрегаций, поэтому может работать медленно при большом количестве данных +- Рекомендуется кэшировать результат на клиенте на 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(); + const { data, loading } = useStatsV2(selectedChainId); + + if (loading) return
Загрузка...
; + + return ( +
+ + + + +
+ ); +} +``` + +### Фильтрация и сортировка + +Все массивы данных можно фильтровать и сортировать на клиенте: + +```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 ; +} +``` + +## Когда использовать фильтрацию по цепочке + +### Используйте `chainId` когда: + +1. **Страница отдельной цепочки** - пользователь просматривает детали конкретной цепочки + ```tsx + function ChainDetailsPage({ chainId }) { + const { data } = useStatsV2(chainId); + return ; + } + ``` + +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 компонентов + diff --git a/locales/en.json b/locales/en.json index 31cd851..c5c1bb5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -178,6 +178,7 @@ "challenge.admin.submissions.details.close": "Close", "challenge.admin.layout.title": "Challenge Admin", "challenge.admin.layout.nav.dashboard": "Dashboard", + "challenge.admin.layout.nav.detailed.stats": "Detailed Statistics", "challenge.admin.layout.nav.tasks": "Tasks", "challenge.admin.layout.nav.chains": "Chains", "challenge.admin.layout.nav.users": "Users", @@ -188,5 +189,46 @@ "challenge.admin.common.error.default": "An error occurred while loading data", "challenge.admin.common.retry": "Try again", "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" } \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index b2cbe4e..9592f78 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -177,6 +177,7 @@ "challenge.admin.submissions.details.close": "Закрыть", "challenge.admin.layout.title": "Challenge Admin", "challenge.admin.layout.nav.dashboard": "Dashboard", + "challenge.admin.layout.nav.detailed.stats": "Детальная статистика", "challenge.admin.layout.nav.tasks": "Задания", "challenge.admin.layout.nav.chains": "Цепочки", "challenge.admin.layout.nav.users": "Пользователи", @@ -187,5 +188,46 @@ "challenge.admin.common.error.default": "Произошла ошибка при загрузке данных", "challenge.admin.common.retry": "Попробовать снова", "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": "Завершено" } \ No newline at end of file diff --git a/src/__data__/api/api.ts b/src/__data__/api/api.ts index 6790690..364f830 100644 --- a/src/__data__/api/api.ts +++ b/src/__data__/api/api.ts @@ -8,6 +8,7 @@ import type { ChallengeUser, ChallengeSubmission, SystemStats, + SystemStatsV2, UserStats, CreateTaskRequest, UpdateTaskRequest, @@ -125,6 +126,14 @@ export const api = createApi({ transformResponse: (response: { body: SystemStats }) => response.body, providesTags: ['Stats'], }), + getSystemStatsV2: builder.query({ + query: (chainId) => ({ + url: '/challenge/stats/v2', + params: chainId ? { chainId } : undefined, + }), + transformResponse: (response: { body: SystemStatsV2 }) => response.body, + providesTags: ['Stats'], + }), getUserStats: builder.query({ query: (userId) => `/challenge/user/${userId}/stats`, transformResponse: (response: { body: UserStats }) => response.body, @@ -161,6 +170,7 @@ export const { useDeleteChainMutation, useGetUsersQuery, useGetSystemStatsQuery, + useGetSystemStatsV2Query, useGetUserStatsQuery, useGetUserSubmissionsQuery, useGetAllSubmissionsQuery, diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index 982870a..d0df8a5 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -12,6 +12,11 @@ export const URLs = { // Dashboard dashboard: makeUrl(''), + // Detailed Stats + detailedStats: makeUrl('/detailed-stats'), + detailedStatsChain: (chainId: string) => makeUrl(`/detailed-stats/${chainId}`), + detailedStatsChainPath: makeUrl('/detailed-stats/:chainId'), + // Tasks tasks: makeUrl('/tasks'), taskNew: makeUrl('/tasks/new'), diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3e5c355..1d68b38 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,7 +1,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' 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 { URLs } from '../__data__/urls' import { keycloak } from '../__data__/kc' @@ -34,6 +34,7 @@ export const Layout: React.FC = ({ children }) => { const navItems = [ { 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.chains'), path: URLs.chains }, { label: t('challenge.admin.layout.nav.users'), path: URLs.users }, @@ -106,16 +107,15 @@ export const Layout: React.FC = ({ children }) => { {navItems.map((item) => ( - + + + ))} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 2be7f2a..351b0a5 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom' import { Layout } from './components/Layout' import { DashboardPage } from './pages/dashboard/DashboardPage' +import { DetailedStatsPage } from './pages/detailed-stats/DetailedStatsPage' import { TasksListPage } from './pages/tasks/TasksListPage' import { TaskFormPage } from './pages/tasks/TaskFormPage' import { ChainsListPage } from './pages/chains/ChainsListPage' @@ -30,6 +31,24 @@ export const Dashboard = () => { } /> + {/* Detailed Stats */} + + + + } + /> + + + + } + /> + {/* Tasks */} string): StatusConfig => { + const configs: Record = { + 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 = ({ chains }) => { + const { t } = useTranslation() + + if (chains.length === 0) { + return ( + + + {t('challenge.admin.detailed.stats.chains.title')} + + + {t('challenge.admin.detailed.stats.chains.empty')} + + + ) + } + + const chain = chains[0] // Теперь всегда одна цепочка из API + + return ( + + + {t('challenge.admin.detailed.stats.chains.title')} + + + + + {chain.name} + + + {t('challenge.admin.detailed.stats.chains.total.tasks')} {chain.totalTasks} + + + + {chain.participantProgress.length > 0 ? ( + + + + + + {t('challenge.admin.detailed.stats.chains.participant')} + + {chain.tasks.map((task) => ( + + + {task.title} + + + ))} + + {t('challenge.admin.detailed.stats.chains.progress')} + + + + + {chain.participantProgress.map((participant) => ( + + + {participant.nickname} + + {participant.taskProgress.map((taskProg) => { + const config = getStatusConfig(taskProg.status, t) + return ( + + + {config.label} + + + ) + })} + + = 80 + ? 'green' + : participant.progressPercent >= 50 + ? 'yellow' + : 'red' + } + > + {participant.progressPercent}% + + + {participant.completedCount}/{chain.totalTasks} + + + + ))} + + + + ) : ( + + {t('challenge.admin.detailed.stats.chains.no.participants')} + + )} + + ) +} + diff --git a/src/pages/detailed-stats/DetailedStatsPage.tsx b/src/pages/detailed-stats/DetailedStatsPage.tsx new file mode 100644 index 0000000..ad22d68 --- /dev/null +++ b/src/pages/detailed-stats/DetailedStatsPage.tsx @@ -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 + } + + if (error) { + return ( + + ) + } + + // Если chainId не указан - показываем карточки для выбора + if (!chainId) { + return ( + + + {t('challenge.admin.detailed.stats.title')} + + {t('challenge.admin.detailed.stats.select.chain')} + + + + {chains && chains.length > 0 ? ( + + {chains.map((chain) => ( + + + + + {chain.name} + + + + {chain.tasks.length} {t('challenge.admin.detailed.stats.chain.card.tasks')} + + + + {t('challenge.admin.detailed.stats.chain.card.click')} + + + + + ))} + + ) : ( + + {t('challenge.admin.detailed.stats.no.chains')} + + )} + + ) + } + + // Если 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 ( + + + + + + + + ← {t('challenge.admin.detailed.stats.back.to.chains')} + + + + + {selectedChain?.name || t('challenge.admin.detailed.stats.title')} + + + {t('challenge.admin.detailed.stats.auto.refresh')} + + + + + + {/* Quick Stats Overview */} + + + {t('challenge.admin.detailed.stats.overview.title')} + + + + + {t('challenge.admin.detailed.stats.overview.users')} + + + {stats.users} + + + + + + {t('challenge.admin.detailed.stats.overview.tasks')} + + + {stats.tasks} + + + + + + {t('challenge.admin.detailed.stats.overview.chains')} + + + {stats.chains} + + + + + + {t('challenge.admin.detailed.stats.overview.total.attempts')} + + + {stats.submissions.total} + + + + + + {t('challenge.admin.detailed.stats.overview.successful')} + + + + {stats.submissions.accepted} + + + {acceptanceRate}% + + + + + + + {t('challenge.admin.detailed.stats.overview.in.progress.pending')} + + + + {stats.submissions.inProgress} + + + {stats.submissions.pending} + + + + + + + {t('challenge.admin.detailed.stats.overview.avg.check.time')} + + + {(stats.averageCheckTimeMs / 1000).toFixed(1)}с + + + + + + {/* Main Content - Three Sections */} + {stats && ( + + {/* 1. Tasks Statistics Table */} + + + {/* 2. Active Participants Progress */} + + + {/* 3. Chain Detailed View */} + + + )} + + ) +} + diff --git a/src/pages/detailed-stats/ParticipantsProgress.tsx b/src/pages/detailed-stats/ParticipantsProgress.tsx new file mode 100644 index 0000000..d6d452f --- /dev/null +++ b/src/pages/detailed-stats/ParticipantsProgress.tsx @@ -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 = ({ 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 ( + + + {t('challenge.admin.detailed.stats.participants.title')} + + + {t('challenge.admin.detailed.stats.participants.empty')} + + + ) + } + + return ( + + + {t('challenge.admin.detailed.stats.participants.title')} + + + {participants.map((participant) => ( + + + {/* Participant Header */} + + + {participant.nickname} + + + + {t('challenge.admin.detailed.stats.participants.completed')} + {participant.completedTasks} + + + + {t('challenge.admin.detailed.stats.participants.attempts')} + {participant.totalSubmissions} + + + + + + {/* Chain Progress */} + {participant.chainProgress.length > 0 ? ( + + {participant.chainProgress.map((chain) => ( + + + + {chain.chainName} + + + {chain.completedTasks}/{chain.totalTasks} + + + + + + + + + {chain.progressPercent}% + + + ))} + + ) : ( + + {t('challenge.admin.detailed.stats.participants.no.progress')} + + )} + + + ))} + + + ) +} + diff --git a/src/pages/detailed-stats/TasksStatisticsTable.tsx b/src/pages/detailed-stats/TasksStatisticsTable.tsx new file mode 100644 index 0000000..2b450b4 --- /dev/null +++ b/src/pages/detailed-stats/TasksStatisticsTable.tsx @@ -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 = ({ tasks }) => { + const { t } = useTranslation() + const [sortKey, setSortKey] = useState('successRate') + const [sortDirection, setSortDirection] = useState('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 ( + + + {t('challenge.admin.detailed.stats.tasks.table.title')} + + + {t('challenge.admin.detailed.stats.tasks.table.empty')} + + + ) + } + + return ( + + + {t('challenge.admin.detailed.stats.tasks.table.title')} + + + + + + handleSort('title')} + _hover={{ bg: 'gray.50' }} + > + {t('challenge.admin.detailed.stats.tasks.table.task.name')} {sortKey === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('totalAttempts')} + _hover={{ bg: 'gray.50' }} + textAlign="right" + > + {t('challenge.admin.detailed.stats.tasks.table.attempts')} {sortKey === 'totalAttempts' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('uniqueUsers')} + _hover={{ bg: 'gray.50' }} + textAlign="right" + > + {t('challenge.admin.detailed.stats.tasks.table.users')} {sortKey === 'uniqueUsers' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('acceptedCount')} + _hover={{ bg: 'gray.50' }} + textAlign="right" + > + {t('challenge.admin.detailed.stats.tasks.table.completed')} {sortKey === 'acceptedCount' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('successRate')} + _hover={{ bg: 'gray.50' }} + textAlign="right" + > + {t('challenge.admin.detailed.stats.tasks.table.success.rate')} {sortKey === 'successRate' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('averageAttemptsToSuccess')} + _hover={{ bg: 'gray.50' }} + textAlign="right" + > + {t('challenge.admin.detailed.stats.tasks.table.avg.attempts')} {sortKey === 'averageAttemptsToSuccess' && (sortDirection === 'asc' ? '↑' : '↓')} + + + + + {sortedTasks.map((task) => ( + + {task.title} + {task.totalAttempts} + {task.uniqueUsers} + {task.acceptedCount} + + + {task.successRate}% + + + + {task.averageAttemptsToSuccess.toFixed(1)} + + + ))} + + + + + ) +} + diff --git a/src/types/challenge.ts b/src/types/challenge.ts index 2e43b9d..8116726 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -113,7 +113,7 @@ export interface SystemStats { // API Request/Response types export interface APIResponse { - error: any + error: unknown data: T } @@ -139,3 +139,87 @@ export interface UpdateChainRequest { 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[] +} + diff --git a/stubs/api/README.md b/stubs/api/README.md index 21ee61a..128203b 100644 --- a/stubs/api/README.md +++ b/stubs/api/README.md @@ -11,7 +11,8 @@ stubs/api/ │ ├── chains.json # Цепочки (3 шт.) │ ├── users.json # Пользователи (8 шт.) │ ├── submissions.json # Попытки (8 шт.) -│ └── stats.json # Системная статистика +│ ├── stats.json # Системная статистика (v1) +│ └── stats-v2.json # Детальная статистика (v2, 20 заданий) ├── index.js # API роуты └── README.md # Эта документация ``` @@ -36,7 +37,8 @@ stubs/api/ - `GET /api/challenge/users` - список всех пользователей ### Statistics (Статистика) -- `GET /api/challenge/stats` - общая системная статистика +- `GET /api/challenge/stats` - общая системная статистика (v1) +- `GET /api/challenge/stats/v2` - детальная статистика с таблицами и прогрессом (v2) - `GET /api/challenge/user/:userId/stats` - статистика пользователя (генерируется динамически) ### 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`: diff --git a/stubs/api/data/stats-v2.json b/stubs/api/data/stats-v2.json new file mode 100644 index 0000000..6b1d8ae --- /dev/null +++ b/stubs/api/data/stats-v2.json @@ -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 + } + ] + } + ] +} diff --git a/stubs/api/data/stats.json b/stubs/api/data/stats.json index 41702c4..a5425b7 100644 --- a/stubs/api/data/stats.json +++ b/stubs/api/data/stats.json @@ -1,21 +1,21 @@ { "users": 8, - "tasks": 5, - "chains": 3, + "tasks": 20, + "chains": 2, "submissions": { - "total": 8, - "accepted": 5, - "rejected": 3, - "pending": 0, - "inProgress": 0 + "total": 95, + "accepted": 38, + "rejected": 42, + "pending": 8, + "inProgress": 7 }, - "averageCheckTimeMs": 3275, + "averageCheckTimeMs": 2143, "queue": { - "queueLength": 0, - "waiting": 0, - "inProgress": 0, - "maxConcurrency": 5, - "currentlyProcessing": 0 + "queueLength": 15, + "waiting": 8, + "inProgress": 7, + "maxConcurrency": 10, + "currentlyProcessing": 7 } } diff --git a/stubs/api/index.js b/stubs/api/index.js index 9a5ac54..90e0ab2 100644 --- a/stubs/api/index.js +++ b/stubs/api/index.js @@ -29,6 +29,7 @@ let chainsCache = null; let usersCache = null; let submissionsCache = null; let statsCache = null; +let statsV2Cache = null; const getTasks = () => { if (!tasksCache) tasksCache = loadJSON('tasks.json'); @@ -55,6 +56,11 @@ const getStats = () => { return statsCache; }; +const getStatsV2 = () => { + if (!statsV2Cache) statsV2Cache = loadJSON('stats-v2.json'); + return statsV2Cache; +}; + router.use(timer()); // ============= TASKS ============= @@ -282,6 +288,46 @@ router.get('/challenge/stats', (req, res) => { 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 router.get('/challenge/user/:userId/stats', (req, res) => { const users = getUsers();