diff --git a/CLAUDE.MD b/CLAUDE.MD new file mode 100644 index 0000000..ca3c61b --- /dev/null +++ b/CLAUDE.MD @@ -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. + +### Don’t +- **Don’t hardcode paths in components** (e.g. `'/submissions/...'`); always use `URLs.*` helpers. +- **Don’t 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 ``. +- For **standalone pages**, use plain layout components: + - `Box`, `Heading`, `VStack`, `Grid`, `Progress`, etc. + - No `Dialog*` components on normal routed pages. + +### Don’t +- **Don’t import or use `DialogBody`, `DialogContent`, `DialogHeader`, etc. on regular pages**: + - This causes `useDialogStyles returned 'undefined'` runtime errors. +- **Don’t 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**. + +### Don’t +- **Don’t cast blindly** (`as ChallengeUser`) and then access `.nickname` or `.title` without guards. +- **Don’t 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. + +### Don’t +- **Don’t 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. + +### Don’t +- **Don’t introduce new `t('...')` keys in code without adding them to both locale files**. +- **Don’t 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. + +### Don’t +- **Don’t 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 `` _or_ on standalone pages. + + diff --git a/locales/en.json b/locales/en.json index 24ebb71..6c7ec38 100644 --- a/locales/en.json +++ b/locales/en.json @@ -137,9 +137,12 @@ "challenge.admin.users.stats.chains.progress": "Chain progress", "challenge.admin.users.stats.tasks": "Tasks", "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.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_attempted": "Not attempted", "challenge.admin.users.stats.attempts": "Attempts:", "challenge.admin.users.stats.avg.check.time": "Average check time", "challenge.admin.users.stats.close": "Close", diff --git a/locales/ru.json b/locales/ru.json index e8f6343..d223a17 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -136,9 +136,12 @@ "challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам", "challenge.admin.users.stats.tasks": "Задания", "challenge.admin.users.stats.status.completed": "Завершено", + "challenge.admin.users.stats.status.accepted": "Принято", "challenge.admin.users.stats.status.needs_revision": "Доработка", "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_attempted": "Не пытался", "challenge.admin.users.stats.attempts": "Попыток:", "challenge.admin.users.stats.avg.check.time": "Среднее время проверки", "challenge.admin.users.stats.close": "Закрыть", diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index 3462cb7..64d591e 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -31,6 +31,8 @@ export const URLs = { // Users users: makeUrl('/users'), + userStats: (userId: string) => makeUrl(`/users/${userId}`), + userStatsPath: makeUrl('/users/:userId'), // Submissions submissions: makeUrl('/submissions'), diff --git a/src/dashboard.tsx b/src/dashboard.tsx index d388b18..d6d2ff6 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -9,6 +9,7 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage' import { ChainsListPage } from './pages/chains/ChainsListPage' import { ChainFormPage } from './pages/chains/ChainFormPage' import { UsersPage } from './pages/users/UsersPage' +import { UserStatsPage } from './pages/users/UserStatsPage' import { SubmissionsPage } from './pages/submissions/SubmissionsPage' import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage' import { URLs } from './__data__/urls' @@ -111,6 +112,14 @@ export const Dashboard = () => { } /> + + + + } + /> {/* Submissions */} { + const { t } = useTranslation() + const navigate = useNavigate() + const { userId } = useParams() + + const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, { + skip: !userId, + }) + + const handleBack = () => { + navigate(URLs.users) + } + + if (!userId) { + return ( + + + + + ) + } + + if (isLoading) { + return + } + + if (error) { + return ( + + + + + ) + } + + if (!stats) { + return ( + + + {t('challenge.admin.users.stats.no.data')} + + ) + } + + return ( + + + + + + {t('challenge.admin.users.stats.title')} + + + {/* Overview */} + + + + {t('challenge.admin.users.stats.completed')} + + + {stats.completedTasks} + + + + + {t('challenge.admin.users.stats.total.submissions')} + + + {stats.totalSubmissions} + + + + + {t('challenge.admin.users.stats.in.progress')} + + + {stats.inProgressTasks} + + + + + {t('challenge.admin.users.stats.needs.revision')} + + + {stats.needsRevisionTasks} + + + + + {/* Chains Progress */} + {stats.chainStats.length > 0 && ( + + + {t('challenge.admin.users.stats.chains.progress')} + + + {stats.chainStats.map((chain) => ( + + + + {chain.chainName} + + + {chain.completedTasks} / {chain.totalTasks} + + + + + + + + + ))} + + + )} + + {/* Task Stats */} + {stats.taskStats.length > 0 && ( + + + {t('challenge.admin.users.stats.tasks')} + + + {stats.taskStats.map((taskStat) => { + const getBadgeColor = () => { + if (taskStat.status === 'completed') return 'green' + if (taskStat.status === 'needs_revision') return 'red' + return 'gray' + } + + return ( + + + + {taskStat.taskTitle} + + + {t(`challenge.admin.users.stats.status.${taskStat.status}`)} + + + + {t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts} + + + ) + })} + + + )} + + {/* Average Check Time */} + + + {t('challenge.admin.users.stats.avg.check.time')} + + + {t('challenge.admin.dashboard.check.time.value', { + time: (stats.averageCheckTimeMs / 1000).toFixed(2), + })} + + + + + ) +} + + diff --git a/src/pages/users/UsersPage.tsx b/src/pages/users/UsersPage.tsx index e1f2f73..048f0b1 100644 --- a/src/pages/users/UsersPage.tsx +++ b/src/pages/users/UsersPage.tsx @@ -1,38 +1,21 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - Box, - Heading, - 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 { useNavigate } from 'react-router-dom' +import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react' +import { useGetSystemStatsV2Query } from '../../__data__/api/api' import type { ActiveParticipant } from '../../types/challenge' import { LoadingSpinner } from '../../components/LoadingSpinner' import { ErrorAlert } from '../../components/ErrorAlert' import { EmptyState } from '../../components/EmptyState' +import { URLs } from '../../__data__/urls' export const UsersPage: React.FC = () => { const { t } = useTranslation() + const navigate = useNavigate() const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, { pollingInterval: 10000, }) const [searchQuery, setSearchQuery] = useState('') - const [selectedUserId, setSelectedUserId] = useState(null) if (isLoading) { return @@ -108,7 +91,7 @@ export const UsersPage: React.FC = () => { size="sm" variant="ghost" colorPalette="teal" - onClick={() => setSelectedUserId(user.userId)} + onClick={() => navigate(URLs.userStats(user.userId))} > {t('challenge.admin.users.button.stats')} @@ -120,167 +103,6 @@ export const UsersPage: React.FC = () => { )} - {/* User Stats Modal */} - setSelectedUserId(null)} - /> ) } - -interface UserStatsModalProps { - userId: string | null - isOpen: boolean - onClose: () => void -} - -const UserStatsModal: React.FC = ({ userId, isOpen, onClose }) => { - const { t } = useTranslation() - const { data: stats, isLoading } = useGetUserStatsQuery(userId!, { - skip: !userId, - }) - - return ( - !e.open && onClose()} size="xl"> - - - {t('challenge.admin.users.stats.title')} - - - {isLoading ? ( - - ) : !stats ? ( - {t('challenge.admin.users.stats.no.data')} - ) : ( - - {/* Overview */} - - - - {t('challenge.admin.users.stats.completed')} - - - {stats.completedTasks} - - - - - {t('challenge.admin.users.stats.total.submissions')} - - - {stats.totalSubmissions} - - - - - {t('challenge.admin.users.stats.in.progress')} - - - {stats.inProgressTasks} - - - - - {t('challenge.admin.users.stats.needs.revision')} - - - {stats.needsRevisionTasks} - - - - - {/* Chains Progress */} - {stats.chainStats.length > 0 && ( - - - {t('challenge.admin.users.stats.chains.progress')} - - - {stats.chainStats.map((chain) => ( - - - - {chain.chainName} - - - {chain.completedTasks} / {chain.totalTasks} - - - - - - - - - ))} - - - )} - - {/* Task Stats */} - {stats.taskStats.length > 0 && ( - - - {t('challenge.admin.users.stats.tasks')} - - - {stats.taskStats.map((taskStat) => { - const getBadgeColor = () => { - if (taskStat.status === 'completed') return 'green' - if (taskStat.status === 'needs_revision') return 'red' - return 'gray' - } - - return ( - - - - {taskStat.taskTitle} - - - {t(`challenge.admin.users.stats.status.${taskStat.status}`)} - - - - {t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts} - - - ) - })} - - - )} - - {/* Average Check Time */} - - - {t('challenge.admin.users.stats.avg.check.time')} - - - {t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })} - - - - )} - - - - - - - - - ) -} -