Add user stats page and refactor user navigation; replace modal with dedicated page for user statistics, enhancing routing and UI consistency. Update localization for new status keys in both English and Russian.

This commit is contained in:
2025-12-10 11:11:17 +03:00
parent 06bcb6ee51
commit 7323e80dcb
7 changed files with 335 additions and 184 deletions

116
CLAUDE.MD Normal file
View File

@@ -0,0 +1,116 @@
## Overview
This document summarizes the recent changes around submissions/users pages and records guardrails to avoid similar issues in the future.
We:
- Reworked submissions and user stats UIs to use real routes/pages instead of modals.
- Added compact progress overview for participants.
- Introduced deep-linked details pages for submissions and users.
- Fixed Chakra UI dialog misuse and type/translation issues.
## Routing & Page Structure
### Do
- **Define all routes centrally** in `src/__data__/urls.ts` and `src/dashboard.ts`:
- Add both the **URL builder** (e.g. `submissionDetails(userId, submissionId)`) and the **`:param` path**.
- Wrap pages in `PageWrapper` in `dashboard.tsx`.
- **Use real pages for complex views** (details, stats) instead of large modals:
- Submissions details: `SubmissionDetailsPage` with URL `/submissions/:userId/:submissionId`.
- User stats: `UserStatsPage` with URL `/users/:userId`.
- **Pass IDs via URL**, not only component state:
- Use route params for `userId`, `submissionId`, etc.
- For “return and keep selection”, encode it as a query param (e.g. `?userId=...`) and read it on the list page.
### Dont
- **Dont hardcode paths in components** (e.g. `'/submissions/...'`); always use `URLs.*` helpers.
- **Dont rely solely on local React state for deep links**:
- If a view must be shareable/bookmarkable or restorable on reload, it must be addressable by URL.
## Chakra UI Dialogs & Layout
### Do
- **Use dialog subcomponents only inside a dialog root**:
- If you use `DialogBody`, `DialogContent`, etc., they must be wrapped in `<DialogRoot>`.
- For **standalone pages**, use plain layout components:
- `Box`, `Heading`, `VStack`, `Grid`, `Progress`, etc.
- No `Dialog*` components on normal routed pages.
### Dont
- **Dont import or use `DialogBody`, `DialogContent`, `DialogHeader`, etc. on regular pages**:
- This causes `useDialogStyles returned 'undefined'` runtime errors.
- **Dont mix modal patterns and page patterns**:
- Either a true modal (`DialogRoot` + `DialogContent`) over an existing page,
- Or a full page route with normal layout — not both at the same time.
## Data Safety & Types
### Do
- Assume backend fields can be **either object or ID string**, per `ChallengeSubmission` types:
- Example safe access in submissions:
- Guard before reading `user.nickname` or `task.title`.
- Derive strings like:
- `const nickname = typeof rawUser === 'object' && 'nickname' in rawUser ? rawUser.nickname ?? '' : typeof rawUser === 'string' ? rawUser : ''`.
- Normalize strings before calling `.toLowerCase()`:
- `const normalized = (value ?? '').toLowerCase()`.
- When filtering/searching, **never call string methods on possibly `undefined` or non-object values**.
### Dont
- **Dont cast blindly** (`as ChallengeUser`) and then access `.nickname` or `.title` without guards.
- **Dont call `.toLowerCase()` directly on untrusted values** from API or union-typed fields.
## “Back” Navigation & State Restoration
### Do
- For **details pages that should restore list state**:
- Encode the necessary selection into the URL when navigating _to_ details.
- Example: `SubmissionDetailsPage` returns to `URLs.submissions` with `?userId=...`, and `SubmissionsPage` reads `userId` from `useSearchParams` to preselect the user.
- Prefer **semantic back actions** over bare `navigate(-1)` when the previous page/state is known:
- Use `navigate(URLs.submissions + '?userId=...')` or `navigate(URLs.users)` when appropriate.
### Dont
- **Dont rely on `navigate(-1)`** when:
- The previous page might not be the canonical list page,
- You need a specific state (e.g. selected user) restored.
## i18n / Locales
### Do
- **Keep `ru.json` and `en.json` in sync** for any new keys:
- When adding a key under `challenge.admin.*` in one file, add the corresponding entry in the other.
- For **status enums**, ensure all possible values have translations:
- `challenge.admin.users.stats.status.*` must cover all values of `taskStat.status`.
- `challenge.admin.submissions.status.*` must cover all submission statuses.
- Use **consistent key naming patterns**:
- Example: `challenge.admin.users.stats.status.accepted`, `...status.needs_revision`, etc.
### Dont
- **Dont introduce new `t('...')` keys in code without adding them to both locale files**.
- **Dont reuse unrelated keys** just to avoid adding translations — create clear, specific keys.
## UI Patterns for High-Density Overviews
### Do
- For high-density screens (e.g. 100 participants at once):
- Use **compact cards or rows** with:
- Truncated names (`truncate`),
- Thin `Progress` bars,
- Minimal text (percentage + small counters).
- Sort by progress to surface lagging participants.
### Dont
- **Dont use wide tables** when many rows must fit on one screen; prefer grids or narrow rows with fixed-width text columns and flexible progress area.
## When Adding New Features
Before merging:
- **Check routing**:
- New URL added to `URLs`.
- Route wired in `dashboard.tsx`.
- **Check data safety**:
- No unchecked property access on union/nullable types.
- **Check i18n**:
- New keys exist in both `ru.json` and `en.json`.
- **Check Chakra usage**:
- No `Dialog*` components outside a proper `<DialogRoot>` _or_ on standalone pages.

View File

@@ -137,9 +137,12 @@
"challenge.admin.users.stats.chains.progress": "Chain progress", "challenge.admin.users.stats.chains.progress": "Chain progress",
"challenge.admin.users.stats.tasks": "Tasks", "challenge.admin.users.stats.tasks": "Tasks",
"challenge.admin.users.stats.status.completed": "Completed", "challenge.admin.users.stats.status.completed": "Completed",
"challenge.admin.users.stats.status.accepted": "Accepted",
"challenge.admin.users.stats.status.needs_revision": "Revision", "challenge.admin.users.stats.status.needs_revision": "Revision",
"challenge.admin.users.stats.status.in_progress": "In progress", "challenge.admin.users.stats.status.in_progress": "In progress",
"challenge.admin.users.stats.status.pending": "Pending",
"challenge.admin.users.stats.status.not_started": "Not started", "challenge.admin.users.stats.status.not_started": "Not started",
"challenge.admin.users.stats.status.not_attempted": "Not attempted",
"challenge.admin.users.stats.attempts": "Attempts:", "challenge.admin.users.stats.attempts": "Attempts:",
"challenge.admin.users.stats.avg.check.time": "Average check time", "challenge.admin.users.stats.avg.check.time": "Average check time",
"challenge.admin.users.stats.close": "Close", "challenge.admin.users.stats.close": "Close",

View File

@@ -136,9 +136,12 @@
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам", "challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
"challenge.admin.users.stats.tasks": "Задания", "challenge.admin.users.stats.tasks": "Задания",
"challenge.admin.users.stats.status.completed": "Завершено", "challenge.admin.users.stats.status.completed": "Завершено",
"challenge.admin.users.stats.status.accepted": "Принято",
"challenge.admin.users.stats.status.needs_revision": "Доработка", "challenge.admin.users.stats.status.needs_revision": "Доработка",
"challenge.admin.users.stats.status.in_progress": "В процессе", "challenge.admin.users.stats.status.in_progress": "В процессе",
"challenge.admin.users.stats.status.pending": "Ожидает",
"challenge.admin.users.stats.status.not_started": "Не начато", "challenge.admin.users.stats.status.not_started": "Не начато",
"challenge.admin.users.stats.status.not_attempted": "Не пытался",
"challenge.admin.users.stats.attempts": "Попыток:", "challenge.admin.users.stats.attempts": "Попыток:",
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки", "challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
"challenge.admin.users.stats.close": "Закрыть", "challenge.admin.users.stats.close": "Закрыть",

View File

@@ -31,6 +31,8 @@ export const URLs = {
// Users // Users
users: makeUrl('/users'), users: makeUrl('/users'),
userStats: (userId: string) => makeUrl(`/users/${userId}`),
userStatsPath: makeUrl('/users/:userId'),
// Submissions // Submissions
submissions: makeUrl('/submissions'), submissions: makeUrl('/submissions'),

View File

@@ -9,6 +9,7 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage'
import { ChainsListPage } from './pages/chains/ChainsListPage' import { ChainsListPage } from './pages/chains/ChainsListPage'
import { ChainFormPage } from './pages/chains/ChainFormPage' import { ChainFormPage } from './pages/chains/ChainFormPage'
import { UsersPage } from './pages/users/UsersPage' import { UsersPage } from './pages/users/UsersPage'
import { UserStatsPage } from './pages/users/UserStatsPage'
import { SubmissionsPage } from './pages/submissions/SubmissionsPage' import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage' import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
import { URLs } from './__data__/urls' import { URLs } from './__data__/urls'
@@ -111,6 +112,14 @@ export const Dashboard = () => {
</PageWrapper> </PageWrapper>
} }
/> />
<Route
path={URLs.userStatsPath}
element={
<PageWrapper>
<UserStatsPage />
</PageWrapper>
}
/>
{/* Submissions */} {/* Submissions */}
<Route <Route

View File

@@ -0,0 +1,196 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
import { Box, Heading, Text, Button, Grid, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetUserStatsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { URLs } from '../../__data__/urls'
interface RouteParams {
userId: string
}
export const UserStatsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { userId } = useParams<RouteParams>()
const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
const handleBack = () => {
navigate(URLs.users)
}
if (!userId) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.stats.no.data')} onRetry={handleBack} />
</Box>
)
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
}
if (error) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
</Box>
)
}
if (!stats) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
</Box>
)
}
return (
<Box>
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>{t('challenge.admin.users.stats.title')}</Heading>
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', {
time: (stats.averageCheckTimeMs / 1000).toFixed(2),
})}
</Text>
</Box>
</VStack>
</Box>
)
}

View File

@@ -1,38 +1,21 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { useNavigate } from 'react-router-dom'
Box, import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react'
Heading, import { useGetSystemStatsV2Query } from '../../__data__/api/api'
Table,
Input,
Text,
Button,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Grid,
VStack,
HStack,
Badge,
Progress,
} from '@chakra-ui/react'
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
import type { ActiveParticipant } from '../../types/challenge' import type { ActiveParticipant } from '../../types/challenge'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState' import { EmptyState } from '../../components/EmptyState'
import { URLs } from '../../__data__/urls'
export const UsersPage: React.FC = () => { export const UsersPage: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, { const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
pollingInterval: 10000, pollingInterval: 10000,
}) })
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) { if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.loading')} /> return <LoadingSpinner message={t('challenge.admin.users.loading')} />
@@ -108,7 +91,7 @@ export const UsersPage: React.FC = () => {
size="sm" size="sm"
variant="ghost" variant="ghost"
colorPalette="teal" colorPalette="teal"
onClick={() => setSelectedUserId(user.userId)} onClick={() => navigate(URLs.userStats(user.userId))}
> >
{t('challenge.admin.users.button.stats')} {t('challenge.admin.users.button.stats')}
</Button> </Button>
@@ -120,167 +103,6 @@ export const UsersPage: React.FC = () => {
</Box> </Box>
)} )}
{/* User Stats Modal */}
<UserStatsModal
userId={selectedUserId}
isOpen={!!selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Box> </Box>
) )
} }
interface UserStatsModalProps {
userId: string | null
isOpen: boolean
onClose: () => void
}
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { t } = useTranslation()
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
{isLoading ? (
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
) : !stats ? (
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
) : (
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
</Text>
</Box>
</VStack>
)}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}