482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
|
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
|||
|
|
import { Link } from 'react-router-dom'
|
|||
|
|
import { Clock, Gamepad2, Plus, Trophy, Target, Loader2, ChevronDown, Timer, Play, Square } from 'lucide-react'
|
|||
|
|
import { useTrackingStore } from '../store/tracking'
|
|||
|
|
import { useAuthStore } from '../store/auth'
|
|||
|
|
import { useMarathonStore } from '../store/marathon'
|
|||
|
|
import { GlassCard } from '../components/ui/GlassCard'
|
|||
|
|
import { NeonButton } from '../components/ui/NeonButton'
|
|||
|
|
|
|||
|
|
function formatTime(ms: number): string {
|
|||
|
|
const seconds = Math.floor(ms / 1000)
|
|||
|
|
const minutes = Math.floor(seconds / 60)
|
|||
|
|
const hours = Math.floor(minutes / 60)
|
|||
|
|
|
|||
|
|
if (hours > 0) {
|
|||
|
|
const remainingMinutes = minutes % 60
|
|||
|
|
return `${hours}ч ${remainingMinutes}м`
|
|||
|
|
} else if (minutes > 0) {
|
|||
|
|
return `${minutes}м`
|
|||
|
|
} else {
|
|||
|
|
return `${seconds}с`
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatMinutes(minutes: number): string {
|
|||
|
|
const hours = Math.floor(minutes / 60)
|
|||
|
|
const mins = minutes % 60
|
|||
|
|
|
|||
|
|
if (hours > 0) {
|
|||
|
|
return `${hours}ч ${mins}м`
|
|||
|
|
}
|
|||
|
|
return `${mins}м`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDifficultyColor(difficulty: string): string {
|
|||
|
|
switch (difficulty) {
|
|||
|
|
case 'easy': return 'text-green-400'
|
|||
|
|
case 'medium': return 'text-yellow-400'
|
|||
|
|
case 'hard': return 'text-red-400'
|
|||
|
|
default: return 'text-gray-400'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDifficultyLabel(difficulty: string): string {
|
|||
|
|
switch (difficulty) {
|
|||
|
|
case 'easy': return 'Легкий'
|
|||
|
|
case 'medium': return 'Средний'
|
|||
|
|
case 'hard': return 'Сложный'
|
|||
|
|
default: return difficulty
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function DashboardPage() {
|
|||
|
|
const { user } = useAuthStore()
|
|||
|
|
const { trackedGames, stats, currentGame, loadTrackedGames, updateStats } = useTrackingStore()
|
|||
|
|
const {
|
|||
|
|
marathons,
|
|||
|
|
selectedMarathonId,
|
|||
|
|
currentAssignment,
|
|||
|
|
isLoading,
|
|||
|
|
loadMarathons,
|
|||
|
|
selectMarathon,
|
|||
|
|
syncTime
|
|||
|
|
} = useMarathonStore()
|
|||
|
|
|
|||
|
|
// Monitoring state
|
|||
|
|
const [isMonitoring, setIsMonitoring] = useState(false)
|
|||
|
|
const [localSessionSeconds, setLocalSessionSeconds] = useState(0)
|
|||
|
|
|
|||
|
|
// Refs for time tracking sync
|
|||
|
|
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|||
|
|
const lastSyncedMinutesRef = useRef<number>(0)
|
|||
|
|
const sessionStartRef = useRef<number | null>(null)
|
|||
|
|
|
|||
|
|
// Check if we should track time: any tracked game is running + active assignment exists
|
|||
|
|
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
|
|||
|
|
|
|||
|
|
// Sync time to server
|
|||
|
|
const doSyncTime = useCallback(async () => {
|
|||
|
|
if (!currentAssignment || !isTrackingAssignment) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Calculate total minutes: previous tracked + current session
|
|||
|
|
const sessionDuration = sessionStartRef.current
|
|||
|
|
? Math.floor((Date.now() - sessionStartRef.current) / 60000)
|
|||
|
|
: 0
|
|||
|
|
const totalMinutes = currentAssignment.tracked_time_minutes + sessionDuration
|
|||
|
|
|
|||
|
|
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
|
|||
|
|
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${currentAssignment.id}`)
|
|||
|
|
await syncTime(totalMinutes)
|
|||
|
|
lastSyncedMinutesRef.current = totalMinutes
|
|||
|
|
}
|
|||
|
|
}, [currentAssignment, isTrackingAssignment, syncTime])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadTrackedGames()
|
|||
|
|
loadMarathons()
|
|||
|
|
|
|||
|
|
// Load monitoring status
|
|||
|
|
window.electronAPI.getMonitoringStatus().then(setIsMonitoring)
|
|||
|
|
|
|||
|
|
// Subscribe to tracking updates
|
|||
|
|
const unsubscribe = window.electronAPI.onTrackingUpdate((newStats) => {
|
|||
|
|
updateStats(newStats)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Subscribe to game started event
|
|||
|
|
const unsubGameStarted = window.electronAPI.onGameStarted((gameName, _gameId) => {
|
|||
|
|
console.log(`[Game] Started: ${gameName}`)
|
|||
|
|
sessionStartRef.current = Date.now()
|
|||
|
|
setLocalSessionSeconds(0)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Subscribe to game stopped event
|
|||
|
|
const unsubGameStopped = window.electronAPI.onGameStopped((gameName, _duration) => {
|
|||
|
|
console.log(`[Game] Stopped: ${gameName}`)
|
|||
|
|
sessionStartRef.current = null
|
|||
|
|
setLocalSessionSeconds(0)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Get initial stats
|
|||
|
|
window.electronAPI.getTrackingStats().then(updateStats)
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
unsubscribe()
|
|||
|
|
unsubGameStarted()
|
|||
|
|
unsubGameStopped()
|
|||
|
|
}
|
|||
|
|
}, [loadTrackedGames, loadMarathons, updateStats])
|
|||
|
|
|
|||
|
|
// Setup sync interval and local timer when tracking
|
|||
|
|
useEffect(() => {
|
|||
|
|
let localTimerInterval: NodeJS.Timeout | null = null
|
|||
|
|
|
|||
|
|
if (isTrackingAssignment) {
|
|||
|
|
// Start session if not already started
|
|||
|
|
if (!sessionStartRef.current) {
|
|||
|
|
sessionStartRef.current = Date.now()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Sync immediately when game starts
|
|||
|
|
doSyncTime()
|
|||
|
|
|
|||
|
|
// Setup periodic sync every 60 seconds
|
|||
|
|
syncIntervalRef.current = setInterval(() => {
|
|||
|
|
doSyncTime()
|
|||
|
|
}, 60000)
|
|||
|
|
|
|||
|
|
// Update local timer every second for UI
|
|||
|
|
localTimerInterval = setInterval(() => {
|
|||
|
|
if (sessionStartRef.current) {
|
|||
|
|
setLocalSessionSeconds(Math.floor((Date.now() - sessionStartRef.current) / 1000))
|
|||
|
|
}
|
|||
|
|
}, 1000)
|
|||
|
|
|
|||
|
|
} else {
|
|||
|
|
// Do final sync when game stops
|
|||
|
|
if (syncIntervalRef.current) {
|
|||
|
|
doSyncTime()
|
|||
|
|
clearInterval(syncIntervalRef.current)
|
|||
|
|
syncIntervalRef.current = null
|
|||
|
|
sessionStartRef.current = null
|
|||
|
|
}
|
|||
|
|
setLocalSessionSeconds(0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
if (syncIntervalRef.current) {
|
|||
|
|
clearInterval(syncIntervalRef.current)
|
|||
|
|
syncIntervalRef.current = null
|
|||
|
|
}
|
|||
|
|
if (localTimerInterval) {
|
|||
|
|
clearInterval(localTimerInterval)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [isTrackingAssignment, doSyncTime])
|
|||
|
|
|
|||
|
|
// Toggle monitoring
|
|||
|
|
const toggleMonitoring = async () => {
|
|||
|
|
if (isMonitoring) {
|
|||
|
|
await window.electronAPI.stopMonitoring()
|
|||
|
|
setIsMonitoring(false)
|
|||
|
|
} else {
|
|||
|
|
await window.electronAPI.startMonitoring()
|
|||
|
|
setIsMonitoring(true)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const todayTime = stats?.totalTimeToday || 0
|
|||
|
|
const weekTime = stats?.totalTimeWeek || 0
|
|||
|
|
|
|||
|
|
const selectedMarathon = marathons.find(m => m.id === selectedMarathonId)
|
|||
|
|
|
|||
|
|
const renderCurrentChallenge = () => {
|
|||
|
|
if (isLoading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center py-4">
|
|||
|
|
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (marathons.length === 0) {
|
|||
|
|
return (
|
|||
|
|
<p className="text-gray-400 text-sm">
|
|||
|
|
Нет активных марафонов. Присоединитесь к марафону на сайте.
|
|||
|
|
</p>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!currentAssignment) {
|
|||
|
|
return (
|
|||
|
|
<p className="text-gray-400 text-sm">
|
|||
|
|
Нет активного задания. Крутите колесо на сайте!
|
|||
|
|
</p>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const assignment = currentAssignment
|
|||
|
|
|
|||
|
|
// Playthrough assignment
|
|||
|
|
if (assignment.is_playthrough && assignment.playthrough_info) {
|
|||
|
|
// Use localSessionSeconds for live display (updates every second)
|
|||
|
|
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
|
|||
|
|
const totalSeconds = (assignment.tracked_time_minutes * 60) + sessionSeconds
|
|||
|
|
const totalMinutes = Math.floor(totalSeconds / 60)
|
|||
|
|
const trackedHours = totalMinutes / 60
|
|||
|
|
const estimatedPoints = Math.floor(trackedHours * 30)
|
|||
|
|
|
|||
|
|
// Format with seconds when actively tracking
|
|||
|
|
const formatLiveTime = () => {
|
|||
|
|
if (isTrackingAssignment && sessionSeconds > 0) {
|
|||
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|||
|
|
const mins = Math.floor((totalSeconds % 3600) / 60)
|
|||
|
|
const secs = totalSeconds % 60
|
|||
|
|
if (hours > 0) {
|
|||
|
|
return `${hours}ч ${mins}м ${secs}с`
|
|||
|
|
}
|
|||
|
|
return `${mins}м ${secs}с`
|
|||
|
|
}
|
|||
|
|
return formatMinutes(totalMinutes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<div className="flex items-center gap-2 mb-1">
|
|||
|
|
<h3 className="font-medium text-white">
|
|||
|
|
Прохождение: {assignment.game.title}
|
|||
|
|
</h3>
|
|||
|
|
{isTrackingAssignment && (
|
|||
|
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs text-green-400">
|
|||
|
|
<div className="live-indicator" />
|
|||
|
|
Идёт запись
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{assignment.playthrough_info.description && (
|
|||
|
|
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
|
|||
|
|
{assignment.playthrough_info.description}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
<div className="flex items-center gap-3 text-xs flex-wrap">
|
|||
|
|
{totalSeconds > 0 || isTrackingAssignment ? (
|
|||
|
|
<>
|
|||
|
|
<span className="flex items-center gap-1 text-neon-400">
|
|||
|
|
<Timer className="w-3 h-3" />
|
|||
|
|
{formatLiveTime()}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-neon-400 font-medium">
|
|||
|
|
~{estimatedPoints} очков
|
|||
|
|
</span>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<span className="text-gray-500">
|
|||
|
|
Базово: {assignment.playthrough_info.points} очков
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Challenge assignment
|
|||
|
|
if (assignment.challenge) {
|
|||
|
|
const challenge = assignment.challenge
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<h3 className="font-medium text-white mb-1">{challenge.title}</h3>
|
|||
|
|
<p className="text-xs text-gray-500 mb-1">{challenge.game.title}</p>
|
|||
|
|
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
|
|||
|
|
{challenge.description}
|
|||
|
|
</p>
|
|||
|
|
<div className="flex items-center gap-3 text-xs">
|
|||
|
|
<span className={getDifficultyColor(challenge.difficulty)}>
|
|||
|
|
[{getDifficultyLabel(challenge.difficulty)}]
|
|||
|
|
</span>
|
|||
|
|
<span className="text-neon-400 font-medium">
|
|||
|
|
+{challenge.points} очков
|
|||
|
|
</span>
|
|||
|
|
{challenge.estimated_time && (
|
|||
|
|
<span className="text-gray-500">
|
|||
|
|
~{challenge.estimated_time} мин
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<p className="text-gray-400 text-sm">
|
|||
|
|
Задание загружается...
|
|||
|
|
</p>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-xl font-display font-bold text-white">
|
|||
|
|
Привет, {user?.nickname || 'Игрок'}!
|
|||
|
|
</h1>
|
|||
|
|
<p className="text-sm text-gray-400">
|
|||
|
|
{isMonitoring ? (currentGame ? `Играет: ${currentGame}` : 'Мониторинг активен') : 'Мониторинг выключен'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
{currentGame && isMonitoring && (
|
|||
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/10 border border-green-500/30 rounded-full">
|
|||
|
|
<div className="live-indicator" />
|
|||
|
|
<span className="text-xs text-green-400 font-medium truncate max-w-[100px]">{currentGame}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<button
|
|||
|
|
onClick={toggleMonitoring}
|
|||
|
|
className={`p-2 rounded-lg transition-colors ${
|
|||
|
|
isMonitoring
|
|||
|
|
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
|||
|
|
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
|||
|
|
}`}
|
|||
|
|
title={isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг'}
|
|||
|
|
>
|
|||
|
|
{isMonitoring ? <Square className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Stats cards */}
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<GlassCard variant="neon" className="p-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
|
|||
|
|
<Clock className="w-5 h-5 text-neon-500" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-gray-400">Сегодня</p>
|
|||
|
|
<p className="text-lg font-bold text-white">{formatTime(todayTime)}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</GlassCard>
|
|||
|
|
|
|||
|
|
<GlassCard variant="default" className="p-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
|
|||
|
|
<Trophy className="w-5 h-5 text-accent-500" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-gray-400">За неделю</p>
|
|||
|
|
<p className="text-lg font-bold text-white">{formatTime(weekTime)}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</GlassCard>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Current challenge */}
|
|||
|
|
<GlassCard variant="dark" className="border border-neon-500/20">
|
|||
|
|
<div className="flex items-center justify-between mb-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<Target className="w-5 h-5 text-neon-500" />
|
|||
|
|
<h2 className="font-semibold text-white">Текущий челлендж</h2>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Marathon selector */}
|
|||
|
|
{marathons.length > 1 && (
|
|||
|
|
<div className="relative">
|
|||
|
|
<select
|
|||
|
|
value={selectedMarathonId || ''}
|
|||
|
|
onChange={(e) => selectMarathon(Number(e.target.value))}
|
|||
|
|
className="appearance-none bg-dark-800 border border-dark-600 rounded-lg px-3 py-1.5 pr-8 text-xs text-gray-300 focus:outline-none focus:border-neon-500 cursor-pointer"
|
|||
|
|
>
|
|||
|
|
{marathons.map(m => (
|
|||
|
|
<option key={m.id} value={m.id}>
|
|||
|
|
{m.title.length > 30 ? m.title.substring(0, 30) + '...' : m.title}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Marathon title for single marathon */}
|
|||
|
|
{marathons.length === 1 && selectedMarathon && (
|
|||
|
|
<p className="text-xs text-gray-500 mb-2">{selectedMarathon.title}</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{renderCurrentChallenge()}
|
|||
|
|
</GlassCard>
|
|||
|
|
|
|||
|
|
{/* Tracked games */}
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-center justify-between mb-3">
|
|||
|
|
<h2 className="font-semibold text-white flex items-center gap-2">
|
|||
|
|
<Gamepad2 className="w-5 h-5 text-neon-500" />
|
|||
|
|
Отслеживаемые игры
|
|||
|
|
</h2>
|
|||
|
|
<Link to="/games">
|
|||
|
|
<NeonButton variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />}>
|
|||
|
|
Добавить
|
|||
|
|
</NeonButton>
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{trackedGames.length === 0 ? (
|
|||
|
|
<GlassCard variant="dark" className="text-center py-8">
|
|||
|
|
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
|||
|
|
<p className="text-gray-400 text-sm mb-4">
|
|||
|
|
Нет отслеживаемых игр
|
|||
|
|
</p>
|
|||
|
|
<Link to="/games">
|
|||
|
|
<NeonButton variant="secondary" size="sm">
|
|||
|
|
Добавить игру
|
|||
|
|
</NeonButton>
|
|||
|
|
</Link>
|
|||
|
|
</GlassCard>
|
|||
|
|
) : (
|
|||
|
|
<div className="grid grid-cols-2 gap-2">
|
|||
|
|
{trackedGames.slice(0, 4).map((game) => (
|
|||
|
|
<GlassCard
|
|||
|
|
key={game.id}
|
|||
|
|
variant="default"
|
|||
|
|
hover
|
|||
|
|
className="p-3"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 mb-2">
|
|||
|
|
{currentGame === game.name && <div className="live-indicator" />}
|
|||
|
|
<p className="text-sm font-medium text-white truncate flex-1">
|
|||
|
|
{game.name}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-gray-400">
|
|||
|
|
{formatTime(game.totalTime)}
|
|||
|
|
</p>
|
|||
|
|
</GlassCard>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{trackedGames.length > 4 && (
|
|||
|
|
<Link to="/games" className="block mt-2">
|
|||
|
|
<NeonButton variant="ghost" size="sm" className="w-full">
|
|||
|
|
Показать все ({trackedGames.length})
|
|||
|
|
</NeonButton>
|
|||
|
|
</Link>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|