Time tracker app

This commit is contained in:
2026-01-10 08:24:55 +07:00
parent 3256c40841
commit b6eecc4483
46 changed files with 11368 additions and 2 deletions

View File

@@ -0,0 +1,298 @@
import { useEffect, useState } from 'react'
import { Gamepad2, Plus, Trash2, Search, FolderOpen, Cpu, RefreshCw, Loader2 } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { TrackedProcess } from '@shared/types'
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}с`
}
}
// System processes to filter out
const SYSTEM_PROCESSES = new Set([
'svchost', 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
'dwm', 'explorer', 'taskhost', 'conhost', 'spoolsv', 'searchhost',
'runtimebroker', 'sihost', 'fontdrvhost', 'ctfmon', 'dllhost',
'securityhealthservice', 'searchindexer', 'audiodg', 'wudfhost',
'system', 'registry', 'idle', 'memory compression', 'ntoskrnl',
'shellexperiencehost', 'startmenuexperiencehost', 'applicationframehost',
'systemsettings', 'textinputhost', 'searchui', 'cortana', 'lockapp',
'windowsinternal', 'taskhostw', 'wmiprvse', 'msiexec', 'trustedinstaller',
'tiworker', 'smartscreen', 'securityhealthsystray', 'sgrmbroker',
'gamebarpresencewriter', 'gamebar', 'gamebarftserver',
'microsoftedge', 'msedge', 'chrome', 'firefox', 'opera', 'brave',
'discord', 'slack', 'teams', 'zoom', 'skype',
'powershell', 'cmd', 'windowsterminal', 'code', 'devenv',
'node', 'npm', 'electron', 'vite'
])
export function GamesPage() {
const { trackedGames, currentGame, loadTrackedGames, addGame, removeGame } = useTrackingStore()
const [showAddModal, setShowAddModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [addMode, setAddMode] = useState<'process' | 'manual'>('process')
const [manualGame, setManualGame] = useState({ name: '', executableName: '' })
const [processes, setProcesses] = useState<TrackedProcess[]>([])
const [isLoadingProcesses, setIsLoadingProcesses] = useState(false)
useEffect(() => {
loadTrackedGames()
}, [loadTrackedGames])
const loadProcesses = async () => {
setIsLoadingProcesses(true)
try {
const procs = await window.electronAPI.getRunningProcesses()
// Filter out system processes and already tracked games
const filtered = procs.filter(p => {
const name = p.name.toLowerCase().replace('.exe', '')
return !SYSTEM_PROCESSES.has(name) &&
!trackedGames.some(tg =>
tg.executableName.toLowerCase().replace('.exe', '') === name
)
})
setProcesses(filtered)
} catch (error) {
console.error('Failed to load processes:', error)
} finally {
setIsLoadingProcesses(false)
}
}
useEffect(() => {
if (showAddModal && addMode === 'process') {
loadProcesses()
}
}, [showAddModal, addMode])
const filteredProcesses = processes.filter(
(proc) =>
proc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(proc.windowTitle && proc.windowTitle.toLowerCase().includes(searchQuery.toLowerCase()))
)
const handleAddProcess = async (process: TrackedProcess) => {
const name = process.windowTitle || process.displayName || process.name.replace('.exe', '')
await addGame({
id: `proc_${Date.now()}`,
name: name,
executableName: process.name,
executablePath: process.executablePath,
})
setShowAddModal(false)
setSearchQuery('')
}
const handleAddManualGame = async () => {
if (!manualGame.name || !manualGame.executableName) return
await addGame({
id: `manual_${Date.now()}`,
name: manualGame.name,
executableName: manualGame.executableName,
})
setShowAddModal(false)
setManualGame({ name: '', executableName: '' })
}
const handleRemoveGame = async (gameId: string) => {
if (confirm('Удалить игру из отслеживания?')) {
await removeGame(gameId)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-white flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-neon-500" />
Игры
</h1>
<NeonButton
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowAddModal(true)}
>
Добавить
</NeonButton>
</div>
{/* Games list */}
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-12">
<Gamepad2 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Нет игр</h3>
<p className="text-gray-400 text-sm mb-4">
Добавьте игры для отслеживания времени
</p>
<NeonButton onClick={() => setShowAddModal(true)}>
Добавить игру
</NeonButton>
</GlassCard>
) : (
<div className="space-y-2">
{trackedGames.map((game) => (
<GlassCard
key={game.id}
variant="default"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3 min-w-0">
{currentGame === game.name && <div className="live-indicator flex-shrink-0" />}
<div className="min-w-0">
<p className="font-medium text-white truncate">{game.name}</p>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)} наиграно
{game.steamAppId && ' • Steam'}
</p>
</div>
</div>
<button
onClick={() => handleRemoveGame(game.id)}
className="p-2 text-gray-400 hover:text-red-400 transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</GlassCard>
))}
</div>
)}
{/* Add game modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/80 backdrop-blur-sm">
<GlassCard variant="dark" className="w-full max-w-sm mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Добавить игру</h2>
<button
onClick={() => setShowAddModal(false)}
className="p-1 text-gray-400 hover:text-white"
>
</button>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setAddMode('process')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'process'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<Cpu className="w-3.5 h-3.5" />
Процессы
</button>
<button
onClick={() => setAddMode('manual')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'manual'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<FolderOpen className="w-3.5 h-3.5" />
Вручную
</button>
</div>
{addMode === 'process' && (
<>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Поиск процесса..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
icon={<Search className="w-4 h-4" />}
/>
</div>
<button
onClick={loadProcesses}
disabled={isLoadingProcesses}
className="p-2.5 bg-dark-700 border border-dark-600 rounded-lg text-gray-400 hover:text-white hover:border-neon-500/50 transition-colors disabled:opacity-50"
title="Обновить список"
>
<RefreshCw className={`w-4 h-4 ${isLoadingProcesses ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Запустите игру и нажмите обновить
</p>
<div className="mt-3 space-y-2 overflow-y-auto max-h-52">
{isLoadingProcesses ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : filteredProcesses.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-4">
{processes.length === 0 ? 'Нет подходящих процессов' : 'Ничего не найдено'}
</p>
) : (
filteredProcesses.slice(0, 20).map((proc) => (
<button
key={proc.id}
onClick={() => handleAddProcess(proc)}
className="w-full flex items-start gap-3 p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-left"
>
<Cpu className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">
{proc.windowTitle || proc.displayName || proc.name}
</p>
<p className="text-xs text-gray-500 truncate">
{proc.name}
</p>
</div>
</button>
))
)}
</div>
</>
)}
{addMode === 'manual' && (
<div className="space-y-4">
<Input
label="Название игры"
placeholder="Например: Elden Ring"
value={manualGame.name}
onChange={(e) => setManualGame({ ...manualGame, name: e.target.value })}
/>
<Input
label="Имя процесса (exe)"
placeholder="Например: eldenring.exe"
value={manualGame.executableName}
onChange={(e) => setManualGame({ ...manualGame, executableName: e.target.value })}
/>
<NeonButton
className="w-full"
onClick={handleAddManualGame}
disabled={!manualGame.name || !manualGame.executableName}
>
Добавить
</NeonButton>
</div>
)}
</GlassCard>
</div>
)}
</div>
)
}