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:
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user