Add events history

This commit is contained in:
2025-12-15 22:31:42 +07:00
parent 4239ea8516
commit 9a037cb34f
7 changed files with 801 additions and 231 deletions

View File

@@ -11,7 +11,7 @@ from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant, Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType, Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty EventType, Difficulty, User
) )
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
@@ -130,6 +130,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
activity_data = { activity_data = {
"game": game.title, "game": game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
} }
if active_event: if active_event:
activity_data["event_type"] = active_event.type activity_data["event_type"] = active_event.type
@@ -328,6 +330,7 @@ async def complete_assignment(
db, active_event, participant.id, current_user.id db, active_event, participant.id, current_user.id
) )
total_points += common_enemy_bonus total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
@@ -342,7 +345,9 @@ async def complete_assignment(
# Log activity # Log activity
activity_data = { activity_data = {
"game": full_challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points, "points": total_points,
"streak": participant.current_streak, "streak": participant.current_streak,
} }
@@ -367,6 +372,24 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners # If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners: if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
event_end_activity = Activity( event_end_activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close user_id=current_user.id, # Last completer triggers the close
@@ -375,14 +398,7 @@ async def complete_assignment(
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"), "event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True, "auto_closed": True,
"winners": [ "winners": winners_data,
{
"user_id": w["user_id"],
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
],
}, },
) )
db.add(event_end_activity) db.add(event_end_activity)
@@ -440,7 +456,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Log activity # Log activity
activity_data = { activity_data = {
"game": assignment.challenge.game.title,
"challenge": assignment.challenge.title, "challenge": assignment.challenge.title,
"difficulty": assignment.challenge.difficulty,
"penalty": penalty, "penalty": penalty,
} }
if active_event: if active_event:

View File

@@ -157,13 +157,16 @@ class EventService:
- winners_list: list of winners if event closed, None otherwise - winners_list: list of winners if event closed, None otherwise
""" """
if event.type != EventType.COMMON_ENEMY.value: if event.type != EventType.COMMON_ENEMY.value:
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
return 0, False, None return 0, False, None
data = event.data or {} data = event.data or {}
completions = data.get("completions", []) completions = data.get("completions", [])
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
# Check if already completed # Check if already completed
if any(c["participant_id"] == participant_id for c in completions): if any(c["participant_id"] == participant_id for c in completions):
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
return 0, False, None return 0, False, None
# Add completion # Add completion
@@ -174,6 +177,7 @@ class EventService:
"completed_at": datetime.utcnow().isoformat(), "completed_at": datetime.utcnow().isoformat(),
"rank": rank, "rank": rank,
}) })
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes # Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
event.data = {**data, "completions": completions} event.data = {**data, "completions": completions}
@@ -189,6 +193,7 @@ class EventService:
event.end_time = datetime.utcnow() event.end_time = datetime.utcnow()
event_closed = True event_closed = True
winners_list = completions[:3] # Top 3 winners_list = completions[:3] # Top 3
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
await db.commit() await db.commit()

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell } from 'lucide-react'
import {
formatRelativeTime,
getActivityIcon,
getActivityColor,
getActivityBgClass,
isEventActivity,
formatActivityMessage,
} from '@/utils/activity'
interface ActivityFeedProps {
marathonId: number
className?: string
}
export interface ActivityFeedRef {
refresh: () => void
}
const ITEMS_PER_PAGE = 20
const POLL_INTERVAL = 10000 // 10 seconds
// Важные типы активности для отображения
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
'spin',
'complete',
'drop',
'start_marathon',
'finish_marathon',
'event_start',
'event_end',
'swap',
'rematch',
]
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
({ marathonId, className = '' }, ref) => {
const [activities, setActivities] = useState<Activity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const lastFetchRef = useRef<number>(0)
const loadActivities = useCallback(async (offset = 0, append = false) => {
try {
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
// Фильтруем только важные события
const filtered = response.items.filter(item =>
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
)
if (append) {
setActivities(prev => [...prev, ...filtered])
} else {
setActivities(filtered)
}
setHasMore(response.has_more)
setTotal(filtered.length)
lastFetchRef.current = Date.now()
} catch (error) {
console.error('Failed to load activity feed:', error)
}
}, [marathonId])
// Expose refresh method
useImperativeHandle(ref, () => ({
refresh: () => loadActivities()
}), [loadActivities])
// Initial load
useEffect(() => {
setIsLoading(true)
loadActivities().finally(() => setIsLoading(false))
}, [loadActivities])
// Polling for new activities
useEffect(() => {
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
loadActivities()
}
}, POLL_INTERVAL)
return () => clearInterval(interval)
}, [loadActivities])
const handleLoadMore = async () => {
setIsLoadingMore(true)
await loadActivities(activities.length, true)
setIsLoadingMore(false)
}
if (isLoading) {
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
<div className="flex items-center gap-2 mb-4">
<Bell className="w-5 h-5 text-primary-500" />
<h3 className="font-medium text-white">Активность</h3>
</div>
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
</div>
)
}
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-primary-500" />
<h3 className="font-medium text-white">Активность</h3>
</div>
{total > 0 && (
<span className="text-xs text-gray-500">{total}</span>
)}
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500 text-sm">
Пока нет активности
</div>
) : (
<div className="divide-y divide-gray-700/30">
{activities.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
)}
{/* Load more button */}
{hasMore && (
<div className="p-3 border-t border-gray-700/30">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
>
{isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ChevronDown className="w-4 h-4" />
Загрузить ещё
</>
)}
</button>
</div>
)}
</div>
</div>
)
}
)
ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps {
activity: Activity
}
function ActivityItem({ activity }: ActivityItemProps) {
const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type)
const bgClass = getActivityBgClass(activity.type)
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
if (isEvent) {
return (
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${iconColor}`} />
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
{title}
</span>
</div>
{details && (
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
{details}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
{formatRelativeTime(activity.created_at)}
</div>
</div>
)
}
return (
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0">
{activity.user.avatar_url ? (
<img
src={activity.user.avatar_url}
alt={activity.user.nickname}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<span className="text-xs text-gray-400 font-medium">
{activity.user.nickname.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">
{activity.user.nickname}
</span>
<span className="text-xs text-gray-500">
{formatRelativeTime(activity.created_at)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-300">{title}</span>
</div>
{details && (
<div className="text-sm text-gray-400 mt-1">
{details}
</div>
)}
{extra && (
<div className="text-xs text-gray-500 mt-0.5">
{extra}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -6,6 +6,30 @@ body {
@apply bg-gray-900 text-gray-100 min-h-screen; @apply bg-gray-900 text-gray-100 min-h-screen;
} }
/* Custom scrollbar styles */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Firefox */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4b5563 transparent;
}
@layer components { @layer components {
.btn { .btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api' import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types' import type { Marathon, ActiveEvent, Challenge } from '@/types'
@@ -6,6 +6,7 @@ import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
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 { 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 } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
@@ -21,6 +22,7 @@ export function MarathonPage() {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => { useEffect(() => {
loadMarathon() loadMarathon()
@@ -60,6 +62,8 @@ export function MarathonPage() {
try { try {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
// Refresh activity feed when event changes
activityFeedRef.current?.refresh()
} catch (error) { } catch (error) {
console.error('Failed to refresh event:', error) console.error('Failed to refresh event:', error)
} }
@@ -122,243 +126,261 @@ export function MarathonPage() {
const canDelete = isCreator || user?.role === 'admin' const canDelete = isCreator || user?.role === 'admin'
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
К списку марафонов К списку марафонов
</Link> </Link>
{/* Header */} <div className="flex flex-col lg:flex-row gap-6">
<div className="flex justify-between items-start mb-8"> {/* Main content */}
<div> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2"> {/* Header */}
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1> <div className="flex justify-between items-start mb-8">
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${ <div>
marathon.is_public <div className="flex items-center gap-3 mb-2">
? 'bg-green-900/50 text-green-400' <h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
: 'bg-gray-700 text-gray-300' <span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
}`}> marathon.is_public
{marathon.is_public ? ( ? 'bg-green-900/50 text-green-400'
<><Globe className="w-3 h-3" /> Открытый</> : 'bg-gray-700 text-gray-300'
) : ( }`}>
<><Lock className="w-3 h-3" /> Закрытый</> {marathon.is_public ? (
<><Globe className="w-3 h-3" /> Открытый</>
) : (
<><Lock className="w-3 h-3" /> Закрытый</>
)}
</span>
</div>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
)} )}
</span> </div>
<div className="flex gap-2 flex-wrap justify-end">
{/* Кнопка присоединиться для открытых марафонов */}
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}>
<UserPlus className="w-4 h-4 mr-2" />
Присоединиться
</Button>
)}
{/* Настройка для организаторов */}
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Settings className="w-4 h-4 mr-2" />
Настройка
</Button>
</Link>
)}
{/* Предложить игру для участников (не организаторов) если разрешено */}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Gamepad2 className="w-4 h-4 mr-2" />
Предложить игру
</Button>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<Button>
<Play className="w-4 h-4 mr-2" />
Играть
</Button>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary">
<Trophy className="w-4 h-4 mr-2" />
Рейтинг
</Button>
</Link>
{canDelete && (
<Button
variant="ghost"
onClick={handleDelete}
isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div> </div>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
)}
</div>
<div className="flex gap-2"> {/* Stats */}
{/* Кнопка присоединиться для открытых марафонов */} <div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && ( <Card>
<Button onClick={handleJoinPublic} isLoading={isJoining}> <CardContent className="text-center py-4">
<UserPlus className="w-4 h-4 mr-2" /> <div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
Присоединиться <div className="text-sm text-gray-400 flex items-center justify-center gap-1">
</Button> <Users className="w-4 h-4" />
Участников
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
<div className="text-sm text-gray-400">Игр</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Начало
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<CalendarCheck className="w-4 h-4" />
Конец
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
</div>
{/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && (
<div className="mb-8">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)} )}
{/* Настройка для организаторов */} {/* Event control for organizers */}
{marathon.status === 'preparing' && isOrganizer && ( {marathon.status === 'active' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}> <Card className="mb-8">
<Button variant="secondary"> <CardContent>
<Settings className="w-4 h-4 mr-2" /> <div className="flex items-center justify-between mb-4">
Настройка <h3 className="font-medium text-white flex items-center gap-2">
</Button> <Zap className="w-5 h-5 text-yellow-500" />
</Link> Управление событиями
)} </h3>
<Button
{/* Предложить игру для участников (не организаторов) если разрешено */} variant="ghost"
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && ( size="sm"
<Link to={`/marathons/${id}/lobby`}> onClick={() => setShowEventControl(!showEventControl)}
<Button variant="secondary"> >
<Gamepad2 className="w-4 h-4 mr-2" /> {showEventControl ? 'Скрыть' : 'Показать'}
Предложить игру </Button>
</Button> </div>
</Link> {showEventControl && activeEvent && (
)} <EventControl
marathonId={marathon.id}
{marathon.status === 'active' && isParticipant && ( activeEvent={activeEvent}
<Link to={`/marathons/${id}/play`}> challenges={challenges}
<Button> onEventChange={refreshEvent}
<Play className="w-4 h-4 mr-2" /> />
Играть
</Button>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary">
<Trophy className="w-4 h-4 mr-2" />
Рейтинг
</Button>
</Link>
{canDelete && (
<Button
variant="ghost"
onClick={handleDelete}
isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-5 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Users className="w-4 h-4" />
Участников
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
<div className="text-sm text-gray-400">Игр</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Начало
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<CalendarCheck className="w-4 h-4" />
Конец
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
</div>
{/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && (
<div className="mb-8">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)}
{/* Event control for organizers */}
{marathon.status === 'active' && isOrganizer && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Управление событиями
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEventControl(!showEventControl)}
>
{showEventControl ? 'Скрыть' : 'Показать'}
</Button>
</div>
{showEventControl && activeEvent && (
<EventControl
marathonId={marathon.id}
activeEvent={activeEvent}
challenges={challenges}
onEventChange={refreshEvent}
/>
)}
</CardContent>
</Card>
)}
{/* Invite link */}
{marathon.status !== 'finished' && (
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
<div className="flex items-center gap-3">
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
{getInviteLink()}
</code>
<Button variant="secondary" onClick={copyInviteLink}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)} )}
</Button> </CardContent>
</div> </Card>
<p className="text-sm text-gray-500 mt-2"> )}
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
)}
{/* My stats */} {/* Invite link */}
{marathon.my_participation && ( {marathon.status !== 'finished' && (
<Card> <Card className="mb-8">
<CardContent> <CardContent>
<h3 className="font-medium text-white mb-4">Ваша статистика</h3> <h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
<div className="grid grid-cols-3 gap-4 text-center"> <div className="flex items-center gap-3">
<div> <code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
<div className="text-2xl font-bold text-primary-500"> {getInviteLink()}
{marathon.my_participation.total_points} </code>
<Button variant="secondary" onClick={copyInviteLink}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
</div> </div>
<div className="text-sm text-gray-400">Очков</div> <p className="text-sm text-gray-500 mt-2">
</div> Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
<div> </p>
<div className="text-2xl font-bold text-yellow-500"> </CardContent>
{marathon.my_participation.current_streak} </Card>
)}
{/* My stats */}
{marathon.my_participation && (
<Card>
<CardContent>
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary-500">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-500">
{marathon.my_participation.current_streak}
</div>
<div className="text-sm text-gray-400">Серия</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">
{marathon.my_participation.drop_count}
</div>
<div className="text-sm text-gray-400">Пропусков</div>
</div>
</div> </div>
<div className="text-sm text-gray-400">Серия</div> </CardContent>
</div> </Card>
<div> )}
<div className="text-2xl font-bold text-gray-400"> </div>
{marathon.my_participation.drop_count}
</div> {/* Activity Feed - right sidebar */}
<div className="text-sm text-gray-400">Пропусков</div> {isParticipant && (
</div> <div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-4">
<ActivityFeed
ref={activityFeedRef}
marathonId={marathon.id}
className="lg:max-h-[calc(100vh-8rem)]"
/>
</div> </div>
</CardContent> </div>
</Card> )}
)} </div>
</div> </div>
) )
} }

View File

@@ -256,6 +256,10 @@ export type ActivityType =
| 'add_game' | 'add_game'
| 'approve_game' | 'approve_game'
| 'reject_game' | 'reject_game'
| 'event_start'
| 'event_end'
| 'swap'
| 'rematch'
export interface Activity { export interface Activity {
id: number id: number

View File

@@ -0,0 +1,250 @@
import type { Activity, ActivityType, EventType } from '@/types'
import {
UserPlus,
RotateCcw,
CheckCircle,
XCircle,
Play,
Flag,
Plus,
ThumbsUp,
ThumbsDown,
Zap,
ZapOff,
ArrowLeftRight,
RefreshCw,
type LucideIcon,
} from 'lucide-react'
// Relative time formatting
export function formatRelativeTime(dateStr: string): string {
// Backend saves time in UTC, ensure we parse it correctly
// If the string doesn't end with 'Z', append it to indicate UTC
const utcDateStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'
const date = new Date(utcDateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'только что'
if (diffMin < 60) {
if (diffMin === 1) return '1 мин назад'
if (diffMin < 5) return `${diffMin} мин назад`
if (diffMin < 21 && diffMin > 4) return `${diffMin} мин назад`
return `${diffMin} мин назад`
}
if (diffHour < 24) {
if (diffHour === 1) return '1 час назад'
if (diffHour < 5) return `${diffHour} часа назад`
return `${diffHour} часов назад`
}
if (diffDay === 1) return 'вчера'
if (diffDay < 7) return `${diffDay} дн назад`
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
// Activity icon mapping
export function getActivityIcon(type: ActivityType): LucideIcon {
const icons: Record<ActivityType, LucideIcon> = {
join: UserPlus,
spin: RotateCcw,
complete: CheckCircle,
drop: XCircle,
start_marathon: Play,
finish_marathon: Flag,
add_game: Plus,
approve_game: ThumbsUp,
reject_game: ThumbsDown,
event_start: Zap,
event_end: ZapOff,
swap: ArrowLeftRight,
rematch: RefreshCw,
}
return icons[type] || Zap
}
// Activity color mapping
export function getActivityColor(type: ActivityType): string {
const colors: Record<ActivityType, string> = {
join: 'text-green-400',
spin: 'text-blue-400',
complete: 'text-green-400',
drop: 'text-red-400',
start_marathon: 'text-green-400',
finish_marathon: 'text-gray-400',
add_game: 'text-blue-400',
approve_game: 'text-green-400',
reject_game: 'text-red-400',
event_start: 'text-yellow-400',
event_end: 'text-gray-400',
swap: 'text-purple-400',
rematch: 'text-orange-400',
}
return colors[type] || 'text-gray-400'
}
// Activity background for special events
export function getActivityBgClass(type: ActivityType): string {
if (type === 'event_start') {
return 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border-yellow-700/50'
}
if (type === 'event_end') {
return 'bg-gray-800/50 border-gray-700/50'
}
return 'bg-gray-800/30 border-gray-700/30'
}
// Check if activity is a special event
export function isEventActivity(type: ActivityType): boolean {
return type === 'event_start' || type === 'event_end'
}
// Event type to Russian name mapping
const EVENT_NAMES: Record<EventType, string> = {
golden_hour: 'Золотой час',
common_enemy: 'Общий враг',
double_risk: 'Двойной риск',
jackpot: 'Джекпот',
swap: 'Обмен',
rematch: 'Реванш',
}
// Difficulty translation
const DIFFICULTY_NAMES: Record<string, string> = {
easy: 'Легкий',
medium: 'Средний',
hard: 'Сложный',
}
interface Winner {
nickname: string
rank: number
bonus_points: number
}
// Format activity message
export function formatActivityMessage(activity: Activity): { title: string; details?: string; extra?: string } {
const data = activity.data || {}
switch (activity.type) {
case 'join':
return { title: 'присоединился к марафону' }
case 'spin': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const difficulty = data.difficulty ? DIFFICULTY_NAMES[data.difficulty as string] || '' : ''
return {
title: 'получил задание',
details: challenge || undefined,
extra: [game, difficulty].filter(Boolean).join(' • ') || undefined,
}
}
case 'complete': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const points = data.points ? `+${data.points}` : ''
const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : ''
const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : ''
return {
title: `завершил ${points}`,
details: challenge || undefined,
extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined,
}
}
case 'drop': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
const penalty = data.penalty ? `-${data.penalty}` : ''
const free = data.free_drop ? '(бесплатно)' : ''
return {
title: `пропустил ${penalty} ${free}`.trim(),
details: challenge || undefined,
extra: game || undefined,
}
}
case 'start_marathon':
return { title: 'Марафон начался!' }
case 'finish_marathon':
return { title: 'Марафон завершён!' }
case 'add_game':
return {
title: 'добавил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'approve_game':
return {
title: 'одобрил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'reject_game':
return {
title: 'отклонил игру',
details: (data.game as string) || (data.game_title as string) || undefined,
}
case 'event_start': {
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
return {
title: 'СОБЫТИЕ НАЧАЛОСЬ',
details: eventName,
}
}
case 'event_end': {
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
const winners = data.winners as Winner[] | undefined
let winnersText = ''
if (winners && winners.length > 0) {
const medals = ['🥇', '🥈', '🥉']
winnersText = winners
.map((w) => `${medals[w.rank - 1] || ''} ${w.nickname} +${w.bonus_points}`)
.join(' ')
}
return {
title: 'Событие завершено',
details: eventName,
extra: winnersText || undefined,
}
}
case 'swap': {
const challenge = (data.challenge as string) || ''
const withUser = (data.with_user as string) || ''
return {
title: 'обменялся заданиями',
details: withUser ? `с ${withUser}` : undefined,
extra: challenge || undefined,
}
}
case 'rematch': {
const game = (data.game as string) || ''
const challenge = (data.challenge as string) || ''
return {
title: 'взял реванш',
details: challenge || undefined,
extra: game || undefined,
}
}
default:
return { title: 'выполнил действие' }
}
}