From f57a2ba9ea283cb20443ecba5a04be36faeeb910 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Tue, 16 Dec 2025 02:22:12 +0700 Subject: [PATCH] Add marathon finish button and system --- backend/app/api/v1/wheel.py | 9 ++++++ backend/app/schemas/assignment.py | 1 + frontend/src/pages/MarathonPage.tsx | 40 ++++++++++++++++++++++++- frontend/src/pages/PlayPage.tsx | 46 ++++++++++++++++++++++++----- frontend/src/types/index.ts | 1 + 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index e449fe9..27a45ca 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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, ) diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index c90f0a9..b03557e 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -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 diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index 0ea2101..755fd69 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -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(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 (
@@ -216,6 +242,18 @@ export function MarathonPage() { + {marathon.status === 'active' && isOrganizer && ( + + )} + {canDelete && ( + + + +
+ ) + } + const participation = marathon.my_participation return ( @@ -1092,7 +1124,7 @@ export function PlayPage() { onClick={handleDrop} isLoading={isDropping} > - Пропустить (-{spinResult?.drop_penalty || 0}) + Пропустить (-{currentAssignment.drop_penalty}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1d3e590..0ecbb32 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {