Add marathon finish button and system

This commit is contained in:
2025-12-16 02:22:12 +07:00
parent d96f8de568
commit f57a2ba9ea
5 changed files with 89 additions and 8 deletions

View File

@@ -104,6 +104,10 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if marathon.status != MarathonStatus.ACTIVE.value: if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active") raise HTTPException(status_code=400, detail="Marathon is not active")
# Check if marathon has expired by end_date
if marathon.end_date and datetime.utcnow() > marathon.end_date:
raise HTTPException(status_code=400, detail="Marathon has ended")
participant = await get_participant_or_403(db, current_user.id, marathon_id) participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active regular assignment (event assignments are separate) # Check no active regular assignment (event assignments are separate)
@@ -232,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
# Calculate drop penalty (considers active event for double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
return AssignmentResponse( return AssignmentResponse(
id=assignment.id, id=assignment.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
@@ -255,6 +263,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
) )

View File

@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
streak_at_completion: int | None = None streak_at_completion: int | None = None
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -9,7 +9,7 @@ import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react' import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
export function MarathonPage() { export function MarathonPage() {
@@ -25,6 +25,7 @@ export function MarathonPage() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
const [isFinishing, setIsFinishing] = useState(false)
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null) const activityFeedRef = useRef<ActivityFeedRef>(null)
@@ -125,6 +126,31 @@ export function MarathonPage() {
} }
} }
const handleFinish = async () => {
if (!marathon) return
const confirmed = await confirm({
title: 'Завершить марафон?',
message: 'Марафон будет завершён досрочно. Участники больше не смогут выполнять задания.',
confirmText: 'Завершить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsFinishing(true)
try {
const updated = await marathonsApi.finish(marathon.id)
setMarathon(updated)
toast.success('Марафон завершён')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось завершить марафон')
} finally {
setIsFinishing(false)
}
}
if (isLoading || !marathon) { if (isLoading || !marathon) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
@@ -216,6 +242,18 @@ export function MarathonPage() {
</Button> </Button>
</Link> </Link>
{marathon.status === 'active' && isOrganizer && (
<Button
variant="secondary"
onClick={handleFinish}
isLoading={isFinishing}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
>
<Flag className="w-4 h-4 mr-2" />
Завершить
</Button>
)}
{canDelete && ( {canDelete && (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api' import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types' import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
@@ -22,7 +22,6 @@ export function PlayPage() {
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null) const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [games, setGames] = useState<Game[]>([]) const [games, setGames] = useState<Game[]>([])
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -219,7 +218,6 @@ export function PlayPage() {
try { try {
const result = await wheelApi.spin(parseInt(id)) const result = await wheelApi.spin(parseInt(id))
setSpinResult(result)
return result.game return result.game
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
@@ -256,7 +254,6 @@ export function PlayPage() {
setProofFile(null) setProofFile(null)
setProofUrl('') setProofUrl('')
setComment('') setComment('')
setSpinResult(null)
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
@@ -270,7 +267,7 @@ export function PlayPage() {
const handleDrop = async () => { const handleDrop = async () => {
if (!currentAssignment) return if (!currentAssignment) return
const penalty = spinResult?.drop_penalty || 0 const penalty = currentAssignment.drop_penalty
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Пропустить задание?', title: 'Пропустить задание?',
message: `Вы потеряете ${penalty} очков.`, message: `Вы потеряете ${penalty} очков.`,
@@ -285,7 +282,6 @@ export function PlayPage() {
const result = await wheelApi.drop(currentAssignment.id) const result = await wheelApi.drop(currentAssignment.id)
toast.info(`Пропущено. Штраф: -${result.penalty} очков`) toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
setSpinResult(null)
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
@@ -455,6 +451,42 @@ export function PlayPage() {
return <div>Марафон не найден</div> return <div>Марафон не найден</div>
} }
// Check if marathon has ended by status or by date
const marathonEndDate = marathon.end_date ? new Date(marathon.end_date) : null
const isMarathonExpired = marathonEndDate && new Date() > marathonEndDate
const isMarathonEnded = marathon.status === 'finished' || isMarathonExpired
if (isMarathonEnded) {
return (
<div className="max-w-2xl mx-auto">
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К марафону
</Link>
<Card>
<CardContent className="text-center py-12">
<div className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<Trophy className="w-8 h-8 text-yellow-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Марафон завершён</h2>
<p className="text-gray-400 mb-6">
{marathon.status === 'finished'
? 'Этот марафон был завершён организатором.'
: 'Этот марафон завершился по истечении срока.'}
</p>
<Link to={`/marathons/${id}/leaderboard`}>
<Button>
<Trophy className="w-4 h-4 mr-2" />
Посмотреть итоговый рейтинг
</Button>
</Link>
</CardContent>
</Card>
</div>
)
}
const participation = marathon.my_participation const participation = marathon.my_participation
return ( return (
@@ -1092,7 +1124,7 @@ export function PlayPage() {
onClick={handleDrop} onClick={handleDrop}
isLoading={isDropping} isLoading={isDropping}
> >
Пропустить (-{spinResult?.drop_penalty || 0}) Пропустить (-{currentAssignment.drop_penalty})
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@@ -170,6 +170,7 @@ export interface Assignment {
streak_at_completion: number | null streak_at_completion: number | null
started_at: string started_at: string
completed_at: string | null completed_at: string | null
drop_penalty: number
} }
export interface SpinResult { export interface SpinResult {