From 9a037cb34f8205e775bbdc74d4a785df5c02b833 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Mon, 15 Dec 2025 22:31:42 +0700 Subject: [PATCH] Add events history --- backend/app/api/v1/wheel.py | 36 +- backend/app/services/events.py | 5 + frontend/src/components/ActivityFeed.tsx | 247 ++++++++++++ frontend/src/index.css | 24 ++ frontend/src/pages/MarathonPage.tsx | 466 ++++++++++++----------- frontend/src/types/index.ts | 4 + frontend/src/utils/activity.ts | 250 ++++++++++++ 7 files changed, 801 insertions(+), 231 deletions(-) create mode 100644 frontend/src/components/ActivityFeed.tsx create mode 100644 frontend/src/utils/activity.ts diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 145686c..c2e6215 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -11,7 +11,7 @@ from app.core.config import settings from app.models import ( Marathon, MarathonStatus, Game, Challenge, Participant, Assignment, AssignmentStatus, Activity, ActivityType, - EventType, Difficulty + EventType, Difficulty, User ) from app.schemas import ( SpinResult, AssignmentResponse, CompleteResult, DropResult, @@ -130,6 +130,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) activity_data = { "game": game.title, "challenge": challenge.title, + "difficulty": challenge.difficulty, + "points": challenge.points, } if active_event: activity_data["event_type"] = active_event.type @@ -328,6 +330,7 @@ async def complete_assignment( db, active_event, participant.id, current_user.id ) total_points += common_enemy_bonus + print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") # Update assignment assignment.status = AssignmentStatus.COMPLETED.value @@ -342,7 +345,9 @@ async def complete_assignment( # Log activity activity_data = { + "game": full_challenge.game.title, "challenge": challenge.title, + "difficulty": challenge.difficulty, "points": total_points, "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_closed and common_enemy_winners: 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( marathon_id=marathon_id, 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_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"), "auto_closed": True, - "winners": [ - { - "user_id": w["user_id"], - "rank": w["rank"], - "bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0), - } - for w in common_enemy_winners - ], + "winners": winners_data, }, ) db.add(event_end_activity) @@ -440,7 +456,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS # Log activity activity_data = { + "game": assignment.challenge.game.title, "challenge": assignment.challenge.title, + "difficulty": assignment.challenge.difficulty, "penalty": penalty, } if active_event: diff --git a/backend/app/services/events.py b/backend/app/services/events.py index 41c1f67..74483c9 100644 --- a/backend/app/services/events.py +++ b/backend/app/services/events.py @@ -157,13 +157,16 @@ class EventService: - winners_list: list of winners if event closed, None otherwise """ if event.type != EventType.COMMON_ENEMY.value: + print(f"[COMMON_ENEMY] Event type mismatch: {event.type}") return 0, False, None data = event.data or {} completions = data.get("completions", []) + print(f"[COMMON_ENEMY] Current completions count: {len(completions)}") # Check if already completed if any(c["participant_id"] == participant_id for c in completions): + print(f"[COMMON_ENEMY] Participant {participant_id} already completed") return 0, False, None # Add completion @@ -174,6 +177,7 @@ class EventService: "completed_at": datetime.utcnow().isoformat(), "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 event.data = {**data, "completions": completions} @@ -189,6 +193,7 @@ class EventService: event.end_time = datetime.utcnow() event_closed = True winners_list = completions[:3] # Top 3 + print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}") await db.commit() diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..330b3df --- /dev/null +++ b/frontend/src/components/ActivityFeed.tsx @@ -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( + ({ marathonId, className = '' }, ref) => { + const [activities, setActivities] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [total, setTotal] = useState(0) + const lastFetchRef = useRef(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 ( +
+
+ +

Активность

+
+
+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

Активность

+
+ {total > 0 && ( + {total} + )} +
+ + {/* Activity list */} +
+ {activities.length === 0 ? ( +
+ Пока нет активности +
+ ) : ( +
+ {activities.map((activity) => ( + + ))} +
+ )} + + {/* Load more button */} + {hasMore && ( +
+ +
+ )} +
+
+ ) + } +) + +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 ( +
+
+ + + {title} + +
+ {details && ( +
+ {details} +
+ )} +
+ {formatRelativeTime(activity.created_at)} +
+
+ ) + } + + return ( +
+
+ {/* Avatar */} +
+ {activity.user.avatar_url ? ( + {activity.user.nickname} + ) : ( +
+ + {activity.user.nickname.charAt(0).toUpperCase()} + +
+ )} +
+ + {/* Content */} +
+
+ + {activity.user.nickname} + + + {formatRelativeTime(activity.created_at)} + +
+
+ + {title} +
+ {details && ( +
+ {details} +
+ )} + {extra && ( +
+ {extra} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index a9c95ee..051ae13 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,6 +6,30 @@ body { @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 { .btn { @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index f9d0790..7bde190 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { marathonsApi, eventsApi, challengesApi } from '@/api' import type { Marathon, ActiveEvent, Challenge } from '@/types' @@ -6,6 +6,7 @@ import { Button, Card, CardContent } from '@/components/ui' import { useAuthStore } from '@/store/auth' 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 { format } from 'date-fns' @@ -21,6 +22,7 @@ export function MarathonPage() { const [isDeleting, setIsDeleting] = useState(false) const [isJoining, setIsJoining] = useState(false) const [showEventControl, setShowEventControl] = useState(false) + const activityFeedRef = useRef(null) useEffect(() => { loadMarathon() @@ -60,6 +62,8 @@ export function MarathonPage() { try { const eventData = await eventsApi.getActive(parseInt(id)) setActiveEvent(eventData) + // Refresh activity feed when event changes + activityFeedRef.current?.refresh() } catch (error) { console.error('Failed to refresh event:', error) } @@ -122,243 +126,261 @@ export function MarathonPage() { const canDelete = isCreator || user?.role === 'admin' return ( -
+
{/* Back button */} К списку марафонов - {/* Header */} -
-
-
-

{marathon.title}

- - {marathon.is_public ? ( - <> Открытый - ) : ( - <> Закрытый +
+ {/* Main content */} +
+ {/* Header */} +
+
+
+

{marathon.title}

+ + {marathon.is_public ? ( + <> Открытый + ) : ( + <> Закрытый + )} + +
+ {marathon.description && ( +

{marathon.description}

)} - +
+ +
+ {/* Кнопка присоединиться для открытых марафонов */} + {marathon.is_public && !isParticipant && marathon.status !== 'finished' && ( + + )} + + {/* Настройка для организаторов */} + {marathon.status === 'preparing' && isOrganizer && ( + + + + )} + + {/* Предложить игру для участников (не организаторов) если разрешено */} + {marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && ( + + + + )} + + {marathon.status === 'active' && isParticipant && ( + + + + )} + + + + + + {canDelete && ( + + )} +
- {marathon.description && ( -

{marathon.description}

- )} -
-
- {/* Кнопка присоединиться для открытых марафонов */} - {marathon.is_public && !isParticipant && marathon.status !== 'finished' && ( - + {/* Stats */} +
+ + +
{marathon.participants_count}
+
+ + Участников +
+
+
+ + + +
{marathon.games_count}
+
Игр
+
+
+ + + +
+ {marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'} +
+
+ + Начало +
+
+
+ + + +
+ {marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'} +
+
+ + Конец +
+
+
+ + + +
+ {marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'} +
+
Статус
+
+
+
+ + {/* Active event banner */} + {marathon.status === 'active' && activeEvent?.event && ( +
+ +
)} - {/* Настройка для организаторов */} - {marathon.status === 'preparing' && isOrganizer && ( - - - - )} - - {/* Предложить игру для участников (не организаторов) если разрешено */} - {marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && ( - - - - )} - - {marathon.status === 'active' && isParticipant && ( - - - - )} - - - - - - {canDelete && ( - - )} -
-
- - {/* Stats */} -
- - -
{marathon.participants_count}
-
- - Участников -
-
-
- - - -
{marathon.games_count}
-
Игр
-
-
- - - -
- {marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'} -
-
- - Начало -
-
-
- - - -
- {marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'} -
-
- - Конец -
-
-
- - - -
- {marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'} -
-
Статус
-
-
-
- - {/* Active event banner */} - {marathon.status === 'active' && activeEvent?.event && ( -
- -
- )} - - {/* Event control for organizers */} - {marathon.status === 'active' && isOrganizer && ( - - -
-

- - Управление событиями -

- -
- {showEventControl && activeEvent && ( - - )} -
-
- )} - - {/* Invite link */} - {marathon.status !== 'finished' && ( - - -

Ссылка для приглашения

-
- - {getInviteLink()} - - +
+ {showEventControl && activeEvent && ( + )} - -
-

- Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону -

- - - )} + + + )} - {/* My stats */} - {marathon.my_participation && ( - - -

Ваша статистика

-
-
-
- {marathon.my_participation.total_points} + {/* Invite link */} + {marathon.status !== 'finished' && ( + + +

Ссылка для приглашения

+
+ + {getInviteLink()} + +
-
Очков
-
-
-
- {marathon.my_participation.current_streak} +

+ Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону +

+ + + )} + + {/* My stats */} + {marathon.my_participation && ( + + +

Ваша статистика

+
+
+
+ {marathon.my_participation.total_points} +
+
Очков
+
+
+
+ {marathon.my_participation.current_streak} +
+
Серия
+
+
+
+ {marathon.my_participation.drop_count} +
+
Пропусков
+
-
Серия
-
-
-
- {marathon.my_participation.drop_count} -
-
Пропусков
-
+ + + )} +
+ + {/* Activity Feed - right sidebar */} + {isParticipant && ( +
+
+
- - - )} +
+ )} +
) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f840b8b..986849e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -256,6 +256,10 @@ export type ActivityType = | 'add_game' | 'approve_game' | 'reject_game' + | 'event_start' + | 'event_end' + | 'swap' + | 'rematch' export interface Activity { id: number diff --git a/frontend/src/utils/activity.ts b/frontend/src/utils/activity.ts new file mode 100644 index 0000000..5748c58 --- /dev/null +++ b/frontend/src/utils/activity.ts @@ -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 = { + 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 = { + 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 = { + golden_hour: 'Золотой час', + common_enemy: 'Общий враг', + double_risk: 'Двойной риск', + jackpot: 'Джекпот', + swap: 'Обмен', + rematch: 'Реванш', +} + +// Difficulty translation +const DIFFICULTY_NAMES: Record = { + 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: 'выполнил действие' } + } +}