challenge-admin-pl/docs/CHALLENGE_FRONTEND_GUIDE.md
Primakov Alexandr Alexandrovich e777b57991 init + api use
2025-11-03 17:59:08 +03:00

33 KiB
Raw Blame History

Challenge Service - Руководство для фронтенда

Руководство по интеграции сервиса проверки заданий через LLM в пользовательский интерфейс.

Содержание

  1. Основные сценарии использования
  2. Структура данных
  3. API взаимодействие
  4. Компоненты UI
  5. State Management
  6. Best Practices

Основные сценарии использования

1. Сценарий для студента

┌─────────────────────────────────────────────────────────────┐
│  1. Вход/Регистрация (nickname)                             │
│     POST /api/challenge/auth                                │
│     → Сохранить userId в localStorage/state                 │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  2. Просмотр доступных цепочек заданий                      │
│     GET /api/challenge/chains                               │
│     → Отобразить список с прогрессом                        │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  3. Выбор задания из цепочки                                │
│     → Отобразить описание (Markdown)                        │
│     → Показать историю своих попыток                        │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  4. Написание решения                                       │
│     → Текстовое поле / Code Editor                          │
│     → Кнопка "Отправить на проверку"                        │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  5. Отправка на проверку                                    │
│     POST /api/challenge/submit                              │
│     → Получить queueId                                      │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  6. Ожидание результата (polling)                           │
│     GET /api/challenge/check-status/:queueId                │
│     → Показать индикатор загрузки + позицию в очереди       │
│     → Повторять каждые 2-3 секунды                          │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  7. Получение результата                                    │
│     ✅ ПРИНЯТО → Поздравление + переход к следующему        │
│     ❌ ДОРАБОТКА → Feedback от LLM + возможность повторить  │
└─────────────────────────────────────────────────────────────┘

2. Сценарий для администратора

┌─────────────────────────────────────────────────────────────┐
│  1. Создание заданий                                        │
│     POST /api/challenge/task                                │
│     → Markdown редактор для описания                        │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  2. Создание цепочек                                        │
│     POST /api/challenge/chain                               │
│     → Выбор заданий + порядок                               │
└────────────────┬────────────────────────────────────────────┘
                 │
┌────────────────▼────────────────────────────────────────────┐
│  3. Мониторинг статистики                                   │
│     GET /api/challenge/stats                                │
│     → Dashboard с метриками                                 │
└─────────────────────────────────────────────────────────────┘

Структура данных

User (Пользователь)

interface ChallengeUser {
  _id: string
  id: string
  nickname: string
  createdAt: string // ISO 8601
}

Task (Задание)

interface ChallengeTask {
  _id: string
  id: string
  title: string
  description: string // Markdown (видно всем)
  hiddenInstructions?: string // Скрытые инструкции для LLM (только для преподавателей)
  creator?: object // Данные создателя (только для преподавателей)
  createdAt: string
  updatedAt: string
}

Важно: Поля hiddenInstructions и creator доступны только пользователям с ролью teacher в Keycloak. При запросе заданий обычными студентами эти поля будут отфильтрованы на сервере.

Chain (Цепочка заданий)

interface ChallengeChain {
  _id: string
  id: string
  name: string
  tasks: ChallengeTask[] // Populated
  createdAt: string
  updatedAt: string
}

Submission (Попытка)

type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'

interface ChallengeSubmission {
  _id: string
  id: string
  user: ChallengeUser | string // Populated или ID
  task: ChallengeTask | string // Populated или ID
  result: string // Результат пользователя
  status: SubmissionStatus
  queueId?: string
  feedback?: string // Комментарий от LLM
  submittedAt: string
  checkedAt?: string
  attemptNumber: number
}

Queue Status (Статус проверки)

type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'

interface QueueStatus {
  status: QueueStatusType
  submission?: ChallengeSubmission
  error?: string
  position?: number // Позиция в очереди (если waiting)
}

User Stats (Статистика пользователя)

interface TaskStats {
  taskId: string
  taskTitle: string
  attempts: Array<{
    attemptNumber: number
    status: SubmissionStatus
    submittedAt: string
    checkedAt?: string
    feedback?: string
  }>
  totalAttempts: number
  status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
  lastAttemptAt: string | null
}

interface ChainStats {
  chainId: string
  chainName: string
  totalTasks: number
  completedTasks: number
  progress: number // 0-100
}

interface UserStats {
  totalTasksAttempted: number
  completedTasks: number
  inProgressTasks: number
  needsRevisionTasks: number
  totalSubmissions: number
  averageCheckTimeMs: number
  taskStats: TaskStats[]
  chainStats: ChainStats[]
}

System Stats (Общая статистика)

interface SystemStats {
  users: number
  tasks: number
  chains: number
  submissions: {
    total: number
    accepted: number
    rejected: number
    pending: number
    inProgress: number
  }
  averageCheckTimeMs: number
  queue: {
    queueLength: number
    waiting: number
    inProgress: number
    maxConcurrency: number
    currentlyProcessing: number
  }
}

API взаимодействие

Базовая настройка

const API_BASE_URL = 'http://localhost:8082/api/challenge'

// Универсальная функция для запросов
async function apiRequest<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<{ error: any; data: T }> {
  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  })
  
  return response.json()
}

Примеры использования

1. Аутентификация

async function authenticateUser(nickname: string): Promise<string> {
  const { data, error } = await apiRequest<{ ok: boolean; userId: string }>(
    '/auth',
    {
      method: 'POST',
      body: JSON.stringify({ nickname }),
    }
  )
  
  if (error) throw error
  
  // Сохраняем userId
  localStorage.setItem('challengeUserId', data.userId)
  
  return data.userId
}

2. Получение цепочек

async function getChains(): Promise<ChallengeChain[]> {
  const { data, error } = await apiRequest<ChallengeChain[]>('/chains')
  
  if (error) throw error
  
  return data
}

3. Отправка решения

async function submitSolution(
  userId: string,
  taskId: string,
  result: string
): Promise<{ queueId: string; submissionId: string }> {
  const { data, error } = await apiRequest<{
    queueId: string
    submissionId: string
  }>('/submit', {
    method: 'POST',
    body: JSON.stringify({ userId, taskId, result }),
  })
  
  if (error) throw error
  
  return data
}

4. Polling проверки

async function pollCheckStatus(
  queueId: string,
  onUpdate: (status: QueueStatus) => void,
  interval: number = 2000
): Promise<ChallengeSubmission> {
  return new Promise((resolve, reject) => {
    const checkStatus = async () => {
      try {
        const { data, error } = await apiRequest<QueueStatus>(
          `/check-status/${queueId}`
        )
        
        if (error) {
          reject(error)
          return
        }
        
        onUpdate(data)
        
        if (data.status === 'completed' && data.submission) {
          resolve(data.submission)
        } else if (data.status === 'error') {
          reject(new Error(data.error || 'Check failed'))
        } else {
          // Продолжаем polling
          setTimeout(checkStatus, interval)
        }
      } catch (err) {
        reject(err)
      }
    }
    
    checkStatus()
  })
}

5. Получение статистики пользователя

async function getUserStats(userId: string): Promise<UserStats> {
  const { data, error } = await apiRequest<UserStats>(`/user/${userId}/stats`)
  
  if (error) throw error
  
  return data
}

Компоненты UI

1. AuthForm - Форма входа

// AuthForm.tsx
import { useState } from 'react'

interface AuthFormProps {
  onAuth: (userId: string, nickname: string) => void
}

export function AuthForm({ onAuth }: AuthFormProps) {
  const [nickname, setNickname] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError('')
    
    try {
      const userId = await authenticateUser(nickname)
      onAuth(userId, nickname)
    } catch (err) {
      setError('Ошибка входа. Попробуйте другой nickname.')
    } finally {
      setLoading(false)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Введите ваш nickname"
        value={nickname}
        onChange={(e) => setNickname(e.target.value)}
        minLength={3}
        maxLength={50}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Вход...' : 'Войти'}
      </button>
      {error && <div className="error">{error}</div>}
    </form>
  )
}

2. ChainList - Список цепочек

// ChainList.tsx
import { useState, useEffect } from 'react'

interface ChainListProps {
  userId: string
  onSelectChain: (chain: ChallengeChain) => void
}

export function ChainList({ userId, onSelectChain }: ChainListProps) {
  const [chains, setChains] = useState<ChallengeChain[]>([])
  const [stats, setStats] = useState<UserStats | null>(null)
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    loadData()
  }, [userId])
  
  const loadData = async () => {
    try {
      const [chainsData, statsData] = await Promise.all([
        getChains(),
        getUserStats(userId)
      ])
      setChains(chainsData)
      setStats(statsData)
    } catch (err) {
      console.error(err)
    } finally {
      setLoading(false)
    }
  }
  
  const getChainProgress = (chainId: string) => {
    return stats?.chainStats.find(cs => cs.chainId === chainId)
  }
  
  if (loading) return <div>Загрузка...</div>
  
  return (
    <div className="chain-list">
      {chains.map(chain => {
        const progress = getChainProgress(chain.id)
        
        return (
          <div key={chain.id} className="chain-card" onClick={() => onSelectChain(chain)}>
            <h3>{chain.name}</h3>
            <p>{chain.tasks.length} заданий</p>
            
            {progress && (
              <div className="progress">
                <div className="progress-bar" style={{ width: `${progress.progress}%` }} />
                <span>{progress.completedTasks} / {progress.totalTasks}</span>
              </div>
            )}
          </div>
        )
      })}
    </div>
  )
}

3. TaskView - Просмотр и решение задания

// TaskView.tsx
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'

interface TaskViewProps {
  task: ChallengeTask
  userId: string
  onComplete: () => void
}

export function TaskView({ task, userId, onComplete }: TaskViewProps) {
  const [result, setResult] = useState('')
  const [submitting, setSubmitting] = useState(false)
  
  const handleSubmit = async () => {
    setSubmitting(true)
    
    try {
      const { queueId } = await submitSolution(userId, task.id, result)
      // Переходим к экрану проверки
      // (см. CheckStatusView)
    } catch (err) {
      alert('Ошибка отправки')
    } finally {
      setSubmitting(false)
    }
  }
  
  return (
    <div className="task-view">
      <h2>{task.title}</h2>
      
      <div className="task-description">
        <ReactMarkdown>{task.description}</ReactMarkdown>
      </div>
      
      <div className="solution-editor">
        <h3>Ваше решение:</h3>
        <textarea
          value={result}
          onChange={(e) => setResult(e.target.value)}
          placeholder="Напишите ваше решение здесь..."
          rows={15}
        />
      </div>
      
      <button 
        onClick={handleSubmit}
        disabled={!result.trim() || submitting}
      >
        {submitting ? 'Отправка...' : 'Отправить на проверку'}
      </button>
    </div>
  )
}

4. CheckStatusView - Отслеживание проверки

// CheckStatusView.tsx
import { useState, useEffect } from 'react'

interface CheckStatusViewProps {
  queueId: string
  onComplete: (submission: ChallengeSubmission) => void
}

export function CheckStatusView({ queueId, onComplete }: CheckStatusViewProps) {
  const [status, setStatus] = useState<QueueStatus | null>(null)
  const [error, setError] = useState('')
  
  useEffect(() => {
    pollCheckStatus(queueId, setStatus)
      .then(onComplete)
      .catch((err) => setError(err.message))
  }, [queueId])
  
  if (error) {
    return (
      <div className="check-error">
        <h3> Ошибка проверки</h3>
        <p>{error}</p>
      </div>
    )
  }
  
  if (!status) {
    return <div>Инициализация...</div>
  }
  
  return (
    <div className="check-status">
      {status.status === 'waiting' && (
        <>
          <div className="spinner" />
          <h3> Ожидание в очереди</h3>
          {status.position && <p>Позиция в очереди: {status.position}</p>}
        </>
      )}
      
      {status.status === 'in_progress' && (
        <>
          <div className="spinner" />
          <h3>🔍 Проверяем ваше решение...</h3>
          <p>Это может занять несколько секунд</p>
        </>
      )}
    </div>
  )
}

5. ResultView - Отображение результата

// ResultView.tsx
interface ResultViewProps {
  submission: ChallengeSubmission
  onRetry?: () => void
  onNext?: () => void
}

export function ResultView({ submission, onRetry, onNext }: ResultViewProps) {
  const isAccepted = submission.status === 'accepted'
  
  return (
    <div className={`result-view ${isAccepted ? 'accepted' : 'needs-revision'}`}>
      <div className="result-icon">
        {isAccepted ? '✅' : '❌'}
      </div>
      
      <h2>{isAccepted ? 'Задание принято!' : 'Требуется доработка'}</h2>
      
      <div className="feedback">
        <h3>Комментарий:</h3>
        <p>{submission.feedback}</p>
      </div>
      
      <div className="result-meta">
        <p>Попытка #{submission.attemptNumber}</p>
        <p>Время проверки: {getCheckTime(submission)}</p>
      </div>
      
      <div className="actions">
        {isAccepted && onNext && (
          <button onClick={onNext} className="btn-primary">
            Следующее задание 
          </button>
        )}
        
        {!isAccepted && onRetry && (
          <button onClick={onRetry} className="btn-secondary">
            Попробовать снова
          </button>
        )}
      </div>
    </div>
  )
}

function getCheckTime(submission: ChallengeSubmission): string {
  if (!submission.checkedAt) return 'N/A'
  
  const submitted = new Date(submission.submittedAt)
  const checked = new Date(submission.checkedAt)
  const diff = checked.getTime() - submitted.getTime()
  
  return `${Math.round(diff / 1000)} сек`
}

6. UserStatsView - Статистика пользователя

// UserStatsView.tsx
import { useState, useEffect } from 'react'

interface UserStatsViewProps {
  userId: string
}

export function UserStatsView({ userId }: UserStatsViewProps) {
  const [stats, setStats] = useState<UserStats | null>(null)
  
  useEffect(() => {
    getUserStats(userId).then(setStats)
  }, [userId])
  
  if (!stats) return <div>Загрузка...</div>
  
  return (
    <div className="user-stats">
      <h2>Ваша статистика</h2>
      
      <div className="stats-overview">
        <div className="stat-card">
          <h3>{stats.completedTasks}</h3>
          <p>Задания выполнено</p>
        </div>
        
        <div className="stat-card">
          <h3>{stats.totalTasksAttempted}</h3>
          <p>Всего попыток</p>
        </div>
        
        <div className="stat-card">
          <h3>{Math.round(stats.averageCheckTimeMs / 1000)}с</h3>
          <p>Среднее время проверки</p>
        </div>
      </div>
      
      <h3>Прогресс по цепочкам</h3>
      {stats.chainStats.map(chain => (
        <div key={chain.chainId} className="chain-progress">
          <h4>{chain.chainName}</h4>
          <div className="progress-bar">
            <div style={{ width: `${chain.progress}%` }} />
          </div>
          <span>{chain.completedTasks} / {chain.totalTasks}</span>
        </div>
      ))}
      
      <h3>Детали по заданиям</h3>
      {stats.taskStats.map(taskStat => (
        <div key={taskStat.taskId} className="task-stat">
          <h4>{taskStat.taskTitle}</h4>
          <p>Статус: {getStatusLabel(taskStat.status)}</p>
          <p>Попыток: {taskStat.totalAttempts}</p>
        </div>
      ))}
    </div>
  )
}

function getStatusLabel(status: string): string {
  const labels: Record<string, string> = {
    'not_attempted': 'Не начато',
    'pending': 'Ожидает проверки',
    'in_progress': 'Проверяется',
    'completed': 'Завершено',
    'needs_revision': 'Требует доработки'
  }
  return labels[status] || status
}

7. AdminDashboard - Панель администратора

// AdminDashboard.tsx
import { useState, useEffect } from 'react'

export function AdminDashboard() {
  const [stats, setStats] = useState<SystemStats | null>(null)
  
  useEffect(() => {
    const loadStats = async () => {
      const { data } = await apiRequest<SystemStats>('/stats')
      setStats(data)
    }
    
    loadStats()
    const interval = setInterval(loadStats, 10000) // Обновляем каждые 10 сек
    
    return () => clearInterval(interval)
  }, [])
  
  if (!stats) return <div>Загрузка...</div>
  
  return (
    <div className="admin-dashboard">
      <h2>Панель администратора</h2>
      
      <div className="dashboard-grid">
        <div className="dashboard-card">
          <h3>Пользователи</h3>
          <div className="big-number">{stats.users}</div>
        </div>
        
        <div className="dashboard-card">
          <h3>Задания</h3>
          <div className="big-number">{stats.tasks}</div>
        </div>
        
        <div className="dashboard-card">
          <h3>Цепочки</h3>
          <div className="big-number">{stats.chains}</div>
        </div>
        
        <div className="dashboard-card">
          <h3>Всего проверок</h3>
          <div className="big-number">{stats.submissions.total}</div>
        </div>
      </div>
      
      <div className="submissions-chart">
        <h3>Статистика проверок</h3>
        <div className="chart-bars">
          <div className="bar accepted" style={{ height: `${(stats.submissions.accepted / stats.submissions.total) * 100}%` }}>
            <span>Принято: {stats.submissions.accepted}</span>
          </div>
          <div className="bar rejected" style={{ height: `${(stats.submissions.rejected / stats.submissions.total) * 100}%` }}>
            <span>Отклонено: {stats.submissions.rejected}</span>
          </div>
          <div className="bar pending" style={{ height: `${(stats.submissions.pending / stats.submissions.total) * 100}%` }}>
            <span>Ожидают: {stats.submissions.pending}</span>
          </div>
        </div>
      </div>
      
      <div className="queue-status">
        <h3>Статус очереди</h3>
        <div className="queue-info">
          <p>🔄 В обработке: {stats.queue.currentlyProcessing} / {stats.queue.maxConcurrency}</p>
          <p> В ожидании: {stats.queue.waiting}</p>
          <p>📊 Всего в очереди: {stats.queue.queueLength}</p>
        </div>
      </div>
      
      <div className="avg-time">
        <h3>Среднее время проверки</h3>
        <div className="big-number">{Math.round(stats.averageCheckTimeMs / 1000)}с</div>
      </div>
    </div>
  )
}

State Management

React Context пример

// ChallengeContext.tsx
import { createContext, useContext, useState, useEffect } from 'react'

interface ChallengeContextType {
  userId: string | null
  nickname: string | null
  login: (nickname: string) => Promise<void>
  logout: () => void
  stats: UserStats | null
  refreshStats: () => Promise<void>
}

const ChallengeContext = createContext<ChallengeContextType | undefined>(undefined)

export function ChallengeProvider({ children }: { children: React.ReactNode }) {
  const [userId, setUserId] = useState<string | null>(
    localStorage.getItem('challengeUserId')
  )
  const [nickname, setNickname] = useState<string | null>(
    localStorage.getItem('challengeNickname')
  )
  const [stats, setStats] = useState<UserStats | null>(null)
  
  const login = async (nickname: string) => {
    const userId = await authenticateUser(nickname)
    setUserId(userId)
    setNickname(nickname)
    localStorage.setItem('challengeNickname', nickname)
  }
  
  const logout = () => {
    setUserId(null)
    setNickname(null)
    setStats(null)
    localStorage.removeItem('challengeUserId')
    localStorage.removeItem('challengeNickname')
  }
  
  const refreshStats = async () => {
    if (userId) {
      const userStats = await getUserStats(userId)
      setStats(userStats)
    }
  }
  
  useEffect(() => {
    if (userId) {
      refreshStats()
    }
  }, [userId])
  
  return (
    <ChallengeContext.Provider value={{
      userId,
      nickname,
      login,
      logout,
      stats,
      refreshStats
    }}>
      {children}
    </ChallengeContext.Provider>
  )
}

export function useChallenge() {
  const context = useContext(ChallengeContext)
  if (!context) {
    throw new Error('useChallenge must be used within ChallengeProvider')
  }
  return context
}

Best Practices

1. Polling оптимизация

// Используйте exponential backoff для polling
class PollingManager {
  private intervalId: number | null = null
  private currentDelay = 2000 // Начинаем с 2 секунд
  private maxDelay = 10000 // Максимум 10 секунд
  
  start(callback: () => Promise<boolean>) {
    const poll = async () => {
      const shouldContinue = await callback()
      
      if (shouldContinue) {
        // Увеличиваем delay
        this.currentDelay = Math.min(this.currentDelay * 1.5, this.maxDelay)
        this.intervalId = window.setTimeout(poll, this.currentDelay)
      }
    }
    
    poll()
  }
  
  stop() {
    if (this.intervalId) {
      clearTimeout(this.intervalId)
      this.intervalId = null
    }
  }
}

2. Обработка ошибок

// Создайте централизованный обработчик ошибок
class ChallengeAPIError extends Error {
  constructor(
    public message: string,
    public statusCode?: number,
    public details?: any
  ) {
    super(message)
  }
}

async function handleAPIError(response: Response) {
  const data = await response.json()
  
  if (data.error) {
    throw new ChallengeAPIError(
      data.error.message || 'Unknown error',
      response.status,
      data.error
    )
  }
  
  return data
}

3. Кеширование

// Кешируйте редко меняющиеся данные
class ChallengeCache {
  private cache = new Map<string, { data: any; expires: number }>()
  
  set(key: string, data: any, ttl: number = 60000) {
    this.cache.set(key, {
      data,
      expires: Date.now() + ttl
    })
  }
  
  get(key: string): any | null {
    const cached = this.cache.get(key)
    
    if (!cached) return null
    
    if (Date.now() > cached.expires) {
      this.cache.delete(key)
      return null
    }
    
    return cached.data
  }
}

const cache = new ChallengeCache()

async function getChainsWithCache(): Promise<ChallengeChain[]> {
  const cached = cache.get('chains')
  if (cached) return cached
  
  const chains = await getChains()
  cache.set('chains', chains, 5 * 60 * 1000) // 5 минут
  
  return chains
}

4. Оптимистичные обновления

// Показывайте изменения сразу, до ответа от сервера
async function optimisticSubmit(
  task: ChallengeTask,
  result: string,
  updateUI: (status: 'submitting' | 'waiting' | 'checking') => void
) {
  updateUI('submitting')
  
  try {
    const { queueId } = await submitSolution(userId, task.id, result)
    updateUI('waiting')
    
    const submission = await pollCheckStatus(queueId, (status) => {
      if (status.status === 'in_progress') {
        updateUI('checking')
      }
    })
    
    return submission
  } catch (err) {
    // Откатываем UI если ошибка
    throw err
  }
}

5. Accessibility

// Добавляйте ARIA атрибуты
<div 
  role="alert" 
  aria-live="polite"
  aria-atomic="true"
>
  {status === 'waiting' && 'Ожидание в очереди...'}
  {status === 'in_progress' && 'Проверка решения...'}
</div>

// Используйте semantic HTML
<button 
  aria-label="Отправить решение на проверку"
  disabled={submitting}
  aria-busy={submitting}
>
  Отправить
</button>

6. Offline support

// Сохраняйте незавершенные решения локально
function saveDraft(taskId: string, result: string) {
  localStorage.setItem(`draft_${taskId}`, result)
}

function loadDraft(taskId: string): string | null {
  return localStorage.getItem(`draft_${taskId}`)
}

function clearDraft(taskId: string) {
  localStorage.removeItem(`draft_${taskId}`)
}

Примеры интеграции

React + TypeScript

Полный пример приложения доступен в репозитории: /examples/react-challenge-app

Vue.js

// composition API
import { ref, onMounted } from 'vue'

export function useChallengeSubmission(taskId: string) {
  const result = ref('')
  const submitting = ref(false)
  const checkStatus = ref<QueueStatus | null>(null)
  
  const submit = async () => {
    submitting.value = true
    
    try {
      const { queueId } = await submitSolution(userId, taskId, result.value)
      
      await pollCheckStatus(queueId, (status) => {
        checkStatus.value = status
      })
    } finally {
      submitting.value = false
    }
  }
  
  return {
    result,
    submitting,
    checkStatus,
    submit
  }
}

Заключение

Этот сервис предоставляет гибкий API для создания различных пользовательских интерфейсов. Основные принципы:

  1. Polling для отслеживания - используйте разумные интервалы
  2. Оптимистичные обновления - для лучшего UX
  3. Кеширование - для производительности
  4. Обработка ошибок - для надежности
  5. Accessibility - для всех пользователей

Используйте предоставленные компоненты как основу и адаптируйте под ваш дизайн-систему!