Add marathon finish button and system
This commit is contained in:
@@ -104,6 +104,10 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||
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)
|
||||
|
||||
# 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
|
||||
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(
|
||||
id=assignment.id,
|
||||
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,
|
||||
started_at=assignment.started_at,
|
||||
completed_at=assignment.completed_at,
|
||||
drop_penalty=drop_penalty,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
|
||||
streak_at_completion: int | None = None
|
||||
started_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useConfirm } from '@/store/confirm'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
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'
|
||||
|
||||
export function MarathonPage() {
|
||||
@@ -25,6 +25,7 @@ export function MarathonPage() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [isFinishing, setIsFinishing] = useState(false)
|
||||
const [showEventControl, setShowEventControl] = useState(false)
|
||||
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) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -216,6 +242,18 @@ export function MarathonPage() {
|
||||
</Button>
|
||||
</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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
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 { SpinWheel } from '@/components/SpinWheel'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
@@ -22,7 +22,6 @@ export function PlayPage() {
|
||||
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -219,7 +218,6 @@ export function PlayPage() {
|
||||
|
||||
try {
|
||||
const result = await wheelApi.spin(parseInt(id))
|
||||
setSpinResult(result)
|
||||
return result.game
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -256,7 +254,6 @@ export function PlayPage() {
|
||||
setProofFile(null)
|
||||
setProofUrl('')
|
||||
setComment('')
|
||||
setSpinResult(null)
|
||||
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
@@ -270,7 +267,7 @@ export function PlayPage() {
|
||||
const handleDrop = async () => {
|
||||
if (!currentAssignment) return
|
||||
|
||||
const penalty = spinResult?.drop_penalty || 0
|
||||
const penalty = currentAssignment.drop_penalty
|
||||
const confirmed = await confirm({
|
||||
title: 'Пропустить задание?',
|
||||
message: `Вы потеряете ${penalty} очков.`,
|
||||
@@ -285,7 +282,6 @@ export function PlayPage() {
|
||||
const result = await wheelApi.drop(currentAssignment.id)
|
||||
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
|
||||
|
||||
setSpinResult(null)
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -455,6 +451,42 @@ export function PlayPage() {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -1092,7 +1124,7 @@ export function PlayPage() {
|
||||
onClick={handleDrop}
|
||||
isLoading={isDropping}
|
||||
>
|
||||
Пропустить (-{spinResult?.drop_penalty || 0})
|
||||
Пропустить (-{currentAssignment.drop_penalty})
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface Assignment {
|
||||
streak_at_completion: number | null
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
drop_penalty: number
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
|
||||
Reference in New Issue
Block a user