Merge branch 'ui-redesign' — Linear/Vercel-inspired UI overhaul
Replaces sidebar with top nav, adds Cmd+K search modal, redesigns kanban cards with glow effects, frosted glass detail panel, stat cards on analytics, refined mappings table, centralized CSS design tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>tasktracker-web</title>
|
||||
<title>TaskTracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
45
TaskTracker.Web/package-lock.json
generated
45
TaskTracker.Web/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.5",
|
||||
"framer-motion": "^12.34.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -3195,6 +3196,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
|
||||
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.34.3",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3469,7 +3497,6 @@
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -3571,7 +3598,6 @@
|
||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -3922,6 +3948,21 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.5",
|
||||
"framer-motion": "^12.34.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -48,11 +48,11 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md bg-[#0f1117] text-white text-sm px-3 py-2 border border-white/10 placeholder-[#64748b] focus:outline-none focus:ring-2 focus:ring-indigo-500/60 focus:border-transparent transition-colors'
|
||||
'w-full rounded-md bg-[var(--color-page)] text-white text-sm px-3 py-2 border border-[var(--color-border)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/60 focus:border-transparent transition-colors'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-[#1a1d27] p-3 space-y-3"
|
||||
className="rounded-lg bg-[var(--color-surface)] p-3 space-y-3"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Title */}
|
||||
@@ -104,7 +104,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-xs text-[#64748b] hover:text-white transition-colors px-2 py-1"
|
||||
className="text-xs text-[var(--color-text-secondary)] hover:text-white transition-colors px-2 py-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -112,7 +112,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || createTask.isPending}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-500
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
|
||||
@@ -62,17 +62,17 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
||||
{/* "All" chip */}
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
!hasActiveFilters
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-white/10" />
|
||||
<div className="w-px h-4 bg-white/[0.06]" />
|
||||
|
||||
{/* Category chips */}
|
||||
{allCategories.map((cat) => {
|
||||
@@ -83,8 +83,8 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||
isActive ? 'text-white' : 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
isActive ? 'text-white' : 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||
}`}
|
||||
style={
|
||||
isActive
|
||||
@@ -108,15 +108,15 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-white/10" />
|
||||
<div className="w-px h-4 bg-white/[0.06]" />
|
||||
|
||||
{/* Has subtasks chip */}
|
||||
<button
|
||||
onClick={toggleHasSubtasks}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||
filters.hasSubtasks
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ListTree size={12} />
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoa
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<div className="rotate-2 scale-105">
|
||||
<div className="rotate-1 scale-[1.03] opacity-90">
|
||||
<TaskCard task={activeTask} onClick={() => {}} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -28,56 +28,56 @@ export default function KanbanColumn({
|
||||
const taskIds = tasks.map((t) => t.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col min-h-[400px] rounded-xl
|
||||
bg-white/[0.02] border transition-all duration-200
|
||||
${isOver ? 'border-indigo-500/30 bg-white/[0.04] shadow-lg shadow-indigo-500/5' : 'border-white/5'}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col min-h-[300px]">
|
||||
{/* Column header */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-white">{label}</h2>
|
||||
<span
|
||||
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color + '20',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
|
||||
{label}
|
||||
</h2>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<div className="h-[2px] rounded-full" style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
|
||||
{/* Cards area */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="flex-1 flex flex-col gap-2 px-3 pb-3 overflow-y-auto"
|
||||
className={`flex-1 flex flex-col gap-2 rounded-lg transition-colors duration-200 py-1 ${
|
||||
isOver ? 'bg-white/[0.02]' : ''
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{/* Empty state */}
|
||||
{tasks.length === 0 && !showForm && (
|
||||
<div className="flex-1 flex items-center justify-center min-h-[80px] rounded-lg border border-dashed border-white/[0.06]">
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">No tasks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add task form / button (Pending column only) */}
|
||||
{/* Add task (Pending column only) */}
|
||||
{status === WorkTaskStatus.Pending && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="mt-2">
|
||||
{showForm ? (
|
||||
<CreateTaskForm onClose={() => setShowForm(false)} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg
|
||||
text-xs text-[#64748b] border border-dashed border-white/10
|
||||
hover:text-white hover:border-white/20 transition-all duration-200"
|
||||
className="flex items-center gap-1.5 w-full py-2 rounded-lg
|
||||
text-[11px] text-[var(--color-text-tertiary)]
|
||||
hover:text-[var(--color-text-secondary)] hover:bg-white/[0.02]
|
||||
transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Task
|
||||
<Plus size={13} />
|
||||
New task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import SearchBar from './SearchBar.tsx'
|
||||
import { LayoutGrid, BarChart3, Link, Plus, Search } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import SearchModal from './SearchModal.tsx'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/board', label: 'Board', icon: LayoutGrid },
|
||||
@@ -10,60 +10,99 @@ const navItems = [
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [showCreateHint, setShowCreateHint] = useState(false)
|
||||
|
||||
// Global Cmd+K handler
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [handleGlobalKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#0f1117] text-white overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className="flex flex-col justify-between shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
width: collapsed ? 60 : 200,
|
||||
background: 'linear-gradient(180deg, #0f1117 0%, #161922 100%)',
|
||||
}}
|
||||
>
|
||||
<nav className="flex flex-col gap-1 mt-4 px-2">
|
||||
<div className="flex flex-col h-screen bg-[var(--color-page)] text-[var(--color-text-primary)] overflow-hidden">
|
||||
{/* Top navigation bar */}
|
||||
<header className="flex items-center h-12 px-4 border-b border-[var(--color-border)] shrink-0 bg-[var(--color-page)]">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-8">
|
||||
<div className="w-5 h-5 rounded bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent-end)] flex items-center justify-center">
|
||||
<span className="text-[10px] font-bold text-white">T</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">TaskTracker</span>
|
||||
</div>
|
||||
|
||||
{/* Nav tabs */}
|
||||
<nav className="flex items-center gap-1">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#6366f1] text-white'
|
||||
: 'text-[#94a3b8] hover:text-white hover:bg-white/5'
|
||||
? 'text-white bg-white/[0.08]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-white hover:bg-white/[0.04]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-center p-3 mb-2 mx-2 rounded-lg text-[#94a3b8] hover:text-white hover:bg-white/5 transition-all duration-200"
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center gap-2 h-7 px-2.5 rounded-md text-[12px] text-[var(--color-text-secondary)] bg-white/[0.04] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] transition-colors mr-2"
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||
<Search size={13} />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="hidden sm:inline text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1 py-0.5 rounded">
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between h-14 px-6 border-b border-white/5 shrink-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight">TaskTracker</h1>
|
||||
<SearchBar onSelect={(taskId) => navigate(`/board?task=${taskId}`)} />
|
||||
</header>
|
||||
{/* New task button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/board')
|
||||
setShowCreateHint(true)
|
||||
setTimeout(() => setShowCreateHint(false), 100)
|
||||
}}
|
||||
className="flex items-center gap-1 h-7 px-2.5 rounded-md text-[12px] font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="hidden sm:inline">New Task</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto p-5">
|
||||
<Outlet context={{ showCreateHint }} />
|
||||
</main>
|
||||
|
||||
{/* Search modal */}
|
||||
{searchOpen && (
|
||||
<SearchModal
|
||||
onSelect={(taskId) => {
|
||||
setSearchOpen(false)
|
||||
navigate(`/board?task=${taskId}`)
|
||||
}}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ interface NotesListProps {
|
||||
notes: TaskNote[]
|
||||
}
|
||||
|
||||
const NOTE_TYPE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
[NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' },
|
||||
[NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' },
|
||||
[NoteType.General]: { label: 'General', color: '#64748b' },
|
||||
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
|
||||
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
@@ -68,12 +68,12 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
||||
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||
Notes
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
||||
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
@@ -86,25 +86,21 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
||||
<div key={note.id} className="text-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: typeConfig.color + '20',
|
||||
color: typeConfig.color,
|
||||
}}
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
|
||||
>
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#64748b]">
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{formatRelativeTime(note.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#c4c9d4] leading-relaxed">{note.content}</p>
|
||||
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{sortedNotes.length === 0 && !showInput && (
|
||||
<p className="text-sm text-[#64748b] italic">No notes yet</p>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] italic">No notes yet</p>
|
||||
)}
|
||||
|
||||
{showInput && (
|
||||
@@ -121,7 +117,7 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
||||
}
|
||||
}}
|
||||
placeholder="Add a note..."
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
||||
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTasks } from '../api/tasks.ts'
|
||||
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
|
||||
import type { WorkTask } from '../types/index.ts'
|
||||
|
||||
interface SearchBarProps {
|
||||
onSelect: (taskId: number) => void
|
||||
}
|
||||
|
||||
export default function SearchBar({ onSelect }: SearchBarProps) {
|
||||
const { data: tasks } = useTasks()
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounce the query by 200ms
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(query)
|
||||
}, 200)
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
// Filter tasks based on debounced query
|
||||
const results: WorkTask[] = (() => {
|
||||
if (!debouncedQuery.trim() || !tasks) return []
|
||||
const q = debouncedQuery.toLowerCase()
|
||||
return tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
(t.description && t.description.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 8)
|
||||
})()
|
||||
|
||||
// Open/close dropdown based on results
|
||||
useEffect(() => {
|
||||
setIsOpen(results.length > 0)
|
||||
setSelectedIndex(0)
|
||||
}, [results.length])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(taskId: number) => {
|
||||
onSelect(taskId)
|
||||
setQuery('')
|
||||
setDebouncedQuery('')
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex].id)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
},
|
||||
[isOpen, results, selectedIndex, handleSelect]
|
||||
)
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||
return col ? col.label : 'Unknown'
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||
return col ? col.color : '#64748b'
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-[300px]">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8] pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
}}
|
||||
placeholder="Search tasks..."
|
||||
className="w-full h-8 pl-9 pr-3 rounded-full bg-[#1a1d27] text-white text-sm placeholder-[#94a3b8] border border-white/5 focus:border-indigo-500/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 rounded-lg bg-[#1a1d27] border border-white/10 shadow-xl shadow-black/40 z-50 overflow-hidden">
|
||||
{results.map((task, index) => {
|
||||
const categoryColor =
|
||||
CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||
const statusColor = getStatusColor(task.status)
|
||||
const statusLabel = getStatusLabel(task.status)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => handleSelect(task.id)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||
index === selectedIndex ? 'bg-[#2a2d37]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className="shrink-0 w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={statusLabel}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<span className="flex-1 text-sm text-white truncate">{task.title}</span>
|
||||
|
||||
{/* Category badge */}
|
||||
{task.category && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: categoryColor + '20',
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{task.category}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
TaskTracker.Web/src/components/SearchModal.tsx
Normal file
192
TaskTracker.Web/src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Search, ArrowRight } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useTasks } from '../api/tasks.ts'
|
||||
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
|
||||
import type { WorkTask } from '../types/index.ts'
|
||||
|
||||
interface SearchModalProps {
|
||||
onSelect: (taskId: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SearchModal({ onSelect, onClose }: SearchModalProps) {
|
||||
const { data: tasks } = useTasks()
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Filter tasks
|
||||
const results: WorkTask[] = (() => {
|
||||
if (!tasks) return []
|
||||
if (!query.trim()) {
|
||||
// Show recent/active tasks when no query
|
||||
return tasks
|
||||
.filter((t) => t.status === 'Active' || t.status === 'Paused' || t.status === 'Pending')
|
||||
.slice(0, 8)
|
||||
}
|
||||
const q = query.toLowerCase()
|
||||
return tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
(t.description && t.description.toLowerCase().includes(q)) ||
|
||||
(t.category && t.category.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 8)
|
||||
})()
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [query])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (results[selectedIndex]) {
|
||||
onSelect(results[selectedIndex].id)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
},
|
||||
[results, selectedIndex, onSelect, onClose]
|
||||
)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const col = COLUMN_CONFIG.find((c) => c.status === status)
|
||||
return col ? col.color : '#64748b'
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
className="relative w-full max-w-lg bg-[var(--color-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
|
||||
<Search size={16} className="text-[var(--color-text-secondary)] shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search tasks..."
|
||||
className="flex-1 bg-transparent text-[var(--color-text-primary)] text-sm placeholder-[var(--color-text-tertiary)] outline-none"
|
||||
/>
|
||||
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1.5 py-0.5 rounded border border-[var(--color-border)]">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||
{!query.trim() && (
|
||||
<div className="px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
Recent tasks
|
||||
</div>
|
||||
)}
|
||||
{results.map((task, index) => {
|
||||
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||
const statusColor = getStatusColor(task.status)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => onSelect(task.id)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
index === selectedIndex ? 'bg-white/[0.06]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className="shrink-0 w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<span className="flex-1 text-sm text-[var(--color-text-primary)] truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
|
||||
{/* Category */}
|
||||
{task.category && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: categoryColor + '15',
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{task.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Arrow hint on selected */}
|
||||
{index === selectedIndex && (
|
||||
<ArrowRight size={12} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : query.trim() ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-secondary)]">
|
||||
No tasks found
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -46,12 +46,12 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
||||
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||
Subtasks
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
||||
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
@@ -67,13 +67,13 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
||||
onClick={() => handleToggle(subtask)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckSquare size={16} className="text-emerald-400 flex-shrink-0" />
|
||||
<CheckSquare size={16} className="text-[var(--color-status-completed)] flex-shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-[#64748b] group-hover:text-white flex-shrink-0" />
|
||||
<Square size={16} className="text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isCompleted ? 'line-through text-[#64748b]' : 'text-white'
|
||||
isCompleted ? 'line-through text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{subtask.title}
|
||||
@@ -84,7 +84,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
||||
|
||||
{showInput && (
|
||||
<div className="flex items-center gap-2 py-1.5 px-1">
|
||||
<Square size={16} className="text-[#64748b] flex-shrink-0" />
|
||||
<Square size={16} className="text-[var(--color-text-secondary)] flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -98,7 +98,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
||||
}
|
||||
}}
|
||||
placeholder="New subtask..."
|
||||
className="flex-1 bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
||||
className="flex-1 bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { CheckSquare, Clock } from 'lucide-react'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { WorkTaskStatus } from '../types/index.ts'
|
||||
import type { WorkTask } from '../types/index.ts'
|
||||
import { CATEGORY_COLORS } from '../lib/constants.ts'
|
||||
@@ -36,7 +36,7 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}
|
||||
|
||||
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||
@@ -48,14 +48,6 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
).length ?? 0
|
||||
const totalSubTasks = task.subTasks?.length ?? 0
|
||||
|
||||
let progressPercent: number | null = null
|
||||
if (task.estimatedMinutes && task.startedAt) {
|
||||
const start = new Date(task.startedAt).getTime()
|
||||
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
|
||||
const elapsedMins = (end - start) / 60_000
|
||||
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -64,62 +56,59 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
{...listeners}
|
||||
onClick={() => onClick(task.id)}
|
||||
className={`
|
||||
relative rounded-lg cursor-grab active:cursor-grabbing
|
||||
bg-[#1a1d27] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-indigo-500/5
|
||||
transition-all duration-200
|
||||
${isActive ? 'ring-1 ring-cyan-400/60 shadow-[0_0_12px_rgba(6,182,212,0.25)] animate-pulse-glow' : 'shadow-md shadow-black/20'}
|
||||
card-glow rounded-xl cursor-grab active:cursor-grabbing
|
||||
bg-[var(--color-surface)] border transition-all duration-200
|
||||
hover:-translate-y-0.5
|
||||
${isActive
|
||||
? 'border-[var(--color-status-active)]/30 animate-pulse-glow'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-hover)]'
|
||||
}
|
||||
${isDragging ? 'shadow-xl shadow-black/40' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Category left border */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<div className="px-3.5 py-3">
|
||||
{/* Title row */}
|
||||
<div className="flex items-start gap-2 mb-1.5">
|
||||
{isActive && (
|
||||
<span className="shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full bg-[var(--color-status-active)] animate-live-dot" />
|
||||
)}
|
||||
<p className="text-[13px] font-medium text-[var(--color-text-primary)] leading-snug flex-1">
|
||||
{task.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pl-4 pr-3 py-3">
|
||||
{/* Title */}
|
||||
<p className="text-sm font-medium text-white leading-snug mb-2 truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-secondary)]">
|
||||
{task.category && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
{task.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{task.category && (
|
||||
<span
|
||||
className="inline-block text-[11px] font-medium px-2 py-0.5 rounded-full mb-2"
|
||||
style={{
|
||||
backgroundColor: categoryColor + '20',
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{task.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{progressPercent !== null && (
|
||||
<div className="h-1 w-full bg-white/5 rounded-full mb-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center justify-between text-[11px] text-[#64748b]">
|
||||
<div className="flex items-center gap-2">
|
||||
{elapsed && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{elapsed && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{totalSubTasks > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckSquare size={12} />
|
||||
{completedSubTasks}/{totalSubTasks}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{Array.from({ length: totalSubTasks }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`w-1 h-1 rounded-full ${
|
||||
i < completedSubTasks
|
||||
? 'bg-[var(--color-status-completed)]'
|
||||
: 'bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-0.5">{completedSubTasks}/{totalSubTasks}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import { WorkTaskStatus } from '../types/index.ts'
|
||||
import {
|
||||
@@ -40,9 +41,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
const completeTask = useCompleteTask()
|
||||
const abandonTask = useAbandonTask()
|
||||
|
||||
// Slide-in animation state
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
// Inline editing states
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
@@ -58,11 +56,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
const categoryInputRef = useRef<HTMLInputElement>(null)
|
||||
const estimateInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Trigger slide-in
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true))
|
||||
}, [])
|
||||
|
||||
// Escape key handler
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -75,10 +68,10 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
setEditingEstimate(false)
|
||||
return
|
||||
}
|
||||
handleClose()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,11 +93,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
if (editingEstimate) estimateInputRef.current?.focus()
|
||||
}, [editingEstimate])
|
||||
|
||||
function handleClose() {
|
||||
setVisible(false)
|
||||
setTimeout(onClose, 200) // wait for slide-out animation
|
||||
}
|
||||
|
||||
// --- Save handlers ---
|
||||
function saveTitle() {
|
||||
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
|
||||
@@ -153,22 +141,25 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 transition-opacity duration-200 ${
|
||||
visible ? 'bg-black/50' : 'bg-black/0'
|
||||
}`}
|
||||
onClick={handleClose}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-[400px] z-50 bg-[#1a1d27] shadow-2xl flex flex-col transition-transform duration-200 ease-out ${
|
||||
visible ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
<motion.div
|
||||
className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
>
|
||||
{isLoading || !task ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-[#64748b]" size={32} />
|
||||
<Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -176,47 +167,47 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4">
|
||||
{/* Close button */}
|
||||
<div className="flex justify-end mb-3">
|
||||
{/* Title row with close button inline */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle()
|
||||
if (e.key === 'Escape') setEditingTitle(false)
|
||||
}}
|
||||
className="w-full bg-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
|
||||
onClick={() => {
|
||||
setTitleValue(task.title)
|
||||
setEditingTitle(true)
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 rounded hover:bg-white/10 text-[#64748b] hover:text-white transition-colors"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle()
|
||||
if (e.key === 'Escape') setEditingTitle(false)
|
||||
}}
|
||||
className="w-full bg-[#0f1117] text-lg font-semibold text-white px-3 py-2 rounded border border-indigo-500 outline-none"
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className="text-lg font-semibold text-white cursor-pointer hover:text-indigo-300 transition-colors"
|
||||
onClick={() => {
|
||||
setTitleValue(task.title)
|
||||
setEditingTitle(true)
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Status badge + Category */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{statusConfig && (
|
||||
<span
|
||||
className="text-[11px] font-semibold uppercase px-2.5 py-1 rounded-full"
|
||||
className="text-[10px] px-2.5 py-1 rounded-full"
|
||||
style={{
|
||||
backgroundColor: statusConfig.color + '20',
|
||||
color: statusConfig.color,
|
||||
@@ -238,11 +229,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
if (e.key === 'Escape') setEditingCategory(false)
|
||||
}}
|
||||
placeholder="Category..."
|
||||
className="bg-[#0f1117] text-xs text-white px-2 py-1 rounded border border-indigo-500 outline-none w-28"
|
||||
className="bg-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[11px] text-[#64748b] cursor-pointer hover:text-white transition-colors px-2.5 py-1 rounded-full bg-white/5"
|
||||
className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] transition-colors px-2.5 py-1 rounded-full bg-white/5"
|
||||
onClick={() => {
|
||||
setCategoryValue(task.category ?? '')
|
||||
setEditingCategory(true)
|
||||
@@ -254,11 +245,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Description */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-2">
|
||||
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
Description
|
||||
</h3>
|
||||
{editingDesc ? (
|
||||
@@ -274,13 +265,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-indigo-500 outline-none resize-none"
|
||||
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none resize-none"
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
|
||||
task.description ? 'text-[#c4c9d4]' : 'text-[#64748b] italic'
|
||||
task.description ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDescValue(task.description ?? '')
|
||||
@@ -292,23 +283,23 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Time */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-3">
|
||||
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
|
||||
Time
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<span className="text-[11px] text-[#64748b] block mb-1">Elapsed</span>
|
||||
<span className="text-sm text-white font-medium">
|
||||
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] font-medium">
|
||||
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] text-[#64748b] block mb-1">Estimate</span>
|
||||
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Estimate</span>
|
||||
{editingEstimate ? (
|
||||
<input
|
||||
ref={estimateInputRef}
|
||||
@@ -321,11 +312,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
if (e.key === 'Escape') setEditingEstimate(false)
|
||||
}}
|
||||
placeholder="min"
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-indigo-500 outline-none"
|
||||
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm text-white font-medium cursor-pointer hover:text-indigo-300 transition-colors"
|
||||
className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] transition-colors"
|
||||
onClick={() => {
|
||||
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
|
||||
setEditingEstimate(true)
|
||||
@@ -342,7 +333,9 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
progressPercent >= 100 ? 'bg-rose-500' : 'bg-indigo-500'
|
||||
progressPercent >= 100
|
||||
? 'bg-rose-500'
|
||||
: 'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
@@ -350,14 +343,14 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Subtasks */}
|
||||
<div className="p-5">
|
||||
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="p-5">
|
||||
@@ -367,13 +360,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
|
||||
{/* Action buttons - fixed at bottom */}
|
||||
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
|
||||
<div className="border-t border-[#2a2d37] p-5 space-y-2">
|
||||
<div className="border-t border-[var(--color-border)] p-5 space-y-2">
|
||||
{task.status === WorkTaskStatus.Pending && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startTask.mutate(taskId)}
|
||||
disabled={startTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
@@ -418,7 +411,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
<button
|
||||
onClick={() => resumeTask.mutate({ id: taskId })}
|
||||
disabled={resumeTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
@@ -442,7 +435,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
||||
|
||||
if (eventsLoading || mappingsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||
Loading activity...
|
||||
</div>
|
||||
)
|
||||
@@ -82,7 +82,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
||||
|
||||
if (sortedEvents.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||
No activity events for this time range.
|
||||
</div>
|
||||
)
|
||||
@@ -90,30 +90,36 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{visibleEvents.map((evt) => {
|
||||
<div className="relative">
|
||||
{visibleEvents.map((evt, idx) => {
|
||||
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
||||
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
||||
const detail = evt.url || evt.windowTitle || ''
|
||||
const isLast = idx === visibleEvents.length - 1
|
||||
|
||||
return (
|
||||
<div key={evt.id} className="flex items-start gap-3 py-3">
|
||||
{/* Category dot */}
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div key={evt.id} className="flex items-start gap-3 relative">
|
||||
{/* Timeline connector + dot */}
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="w-px flex-1 bg-[var(--color-border)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#94a3b8] shrink-0">
|
||||
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
|
||||
{formatTimestamp(evt.timestamp)}
|
||||
</span>
|
||||
<span className="text-sm text-white font-medium truncate">{evt.appName}</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
|
||||
</div>
|
||||
{detail && (
|
||||
<p className="text-xs text-[#64748b] truncate mt-0.5">{detail}</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +130,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
className="mt-3 w-full py-2 text-sm text-[#94a3b8] hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="mt-3 w-full py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Load more ({sortedEvents.length - visibleCount} remaining)
|
||||
</button>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||
Loading category breakdown...
|
||||
</div>
|
||||
)
|
||||
@@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||
No category data available.
|
||||
</div>
|
||||
)
|
||||
@@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backgroundColor: 'var(--color-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div className="text-white text-sm font-medium">{d.name}</div>
|
||||
<div className="text-[#94a3b8] text-xs mt-0.5">
|
||||
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
|
||||
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
|
||||
{d.count} events ({d.percentage}%)
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
||||
{/* Name + bar + stats */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
|
||||
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
|
||||
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
|
||||
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
|
||||
{cat.count} ({cat.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
|
||||
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
||||
|
||||
if (eventsLoading || mappingsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||
Loading timeline...
|
||||
</div>
|
||||
)
|
||||
@@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
||||
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||
No activity data for this time range.
|
||||
</div>
|
||||
)
|
||||
@@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
||||
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
||||
axisLine={{ stroke: '#1e293b' }}
|
||||
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||
axisLine={{ stroke: 'var(--color-border)' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
||||
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
@@ -169,8 +169,8 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backgroundColor: 'var(--color-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
@@ -181,14 +181,14 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backgroundColor: 'var(--color-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
|
||||
<div className="text-white text-sm font-medium mt-0.5">{d.appName}</div>
|
||||
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
|
||||
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
||||
{d.count} events
|
||||
</div>
|
||||
|
||||
@@ -2,22 +2,81 @@
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--color-page: #0a0a0f;
|
||||
--color-surface: #12131a;
|
||||
--color-elevated: #1a1b26;
|
||||
--color-border: rgba(255, 255, 255, 0.06);
|
||||
--color-border-hover: rgba(255, 255, 255, 0.12);
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-tertiary: #334155;
|
||||
--color-accent: #8b5cf6;
|
||||
--color-accent-end: #6366f1;
|
||||
--color-status-active: #3b82f6;
|
||||
--color-status-paused: #eab308;
|
||||
--color-status-completed: #22c55e;
|
||||
--color-status-pending: #64748b;
|
||||
}
|
||||
|
||||
/* Noise grain texture */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
|
||||
/* Active task pulse */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(6, 182, 212, 0.3);
|
||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.5);
|
||||
box-shadow: 0 0 16px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
animation: pulse-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
/* Live dot pulse */
|
||||
@keyframes live-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.animate-live-dot {
|
||||
animation: live-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Card hover glow border */
|
||||
.card-glow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-glow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.1), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-glow:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
@@ -27,10 +86,15 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2d37;
|
||||
background: #1a1b26;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3d47;
|
||||
background: #2a2d37;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const COLUMN_CONFIG = [
|
||||
{ status: 'Pending' as const, label: 'Pending', color: '#94a3b8' },
|
||||
{ status: 'Active' as const, label: 'Active', color: '#06b6d4' },
|
||||
{ status: 'Paused' as const, label: 'Paused', color: '#f59e0b' },
|
||||
{ status: 'Completed' as const, label: 'Completed', color: '#10b981' },
|
||||
{ status: 'Pending' as const, label: 'Pending', color: '#64748b' },
|
||||
{ status: 'Active' as const, label: 'Active', color: '#3b82f6' },
|
||||
{ status: 'Paused' as const, label: 'Paused', color: '#eab308' },
|
||||
{ status: 'Completed' as const, label: 'Completed', color: '#22c55e' },
|
||||
] as const
|
||||
|
||||
export const CATEGORY_COLORS: Record<string, string> = {
|
||||
@@ -12,5 +12,10 @@ export const CATEGORY_COLORS: Record<string, string> = {
|
||||
DevOps: '#f97316',
|
||||
Documentation: '#14b8a6',
|
||||
Design: '#ec4899',
|
||||
Unknown: '#64748b',
|
||||
Testing: '#3b82f6',
|
||||
General: '#64748b',
|
||||
Email: '#f59e0b',
|
||||
Engineering: '#6366f1',
|
||||
LaserCutting: '#ef4444',
|
||||
Unknown: '#475569',
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ export default function Analytics() {
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
{/* Header + Filters */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 className="text-xl font-semibold text-white">Analytics</h1>
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Time range dropdown */}
|
||||
<select
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(Number(e.target.value))}
|
||||
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer"
|
||||
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
|
||||
>
|
||||
{TIME_RANGES.map((r) => (
|
||||
<option key={r.minutes} value={r.minutes}>
|
||||
@@ -39,7 +39,7 @@ export default function Analytics() {
|
||||
<select
|
||||
value={taskId ?? ''}
|
||||
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer max-w-[200px]"
|
||||
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
|
||||
>
|
||||
<option value="">All Tasks</option>
|
||||
{tasks?.map((t) => (
|
||||
@@ -51,32 +51,69 @@ export default function Analytics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
|
||||
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
|
||||
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||
{(() => {
|
||||
const totalMins = tasks?.reduce((acc, t) => {
|
||||
if (!t.startedAt) return acc
|
||||
const start = new Date(t.startedAt).getTime()
|
||||
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
|
||||
return acc + (end - start) / 60000
|
||||
}, 0) ?? 0
|
||||
const hours = Math.floor(totalMins / 60)
|
||||
const mins = Math.floor(totalMins % 60)
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
|
||||
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||
{(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
|
||||
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
|
||||
return top ? top[0] : '\u2014'
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
||||
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||
Activity Timeline
|
||||
</h2>
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||
<Timeline minutes={minutes} taskId={taskId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
||||
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||
Category Breakdown
|
||||
</h2>
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
||||
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||
<ActivityFeed minutes={minutes} taskId={taskId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Trash2, Check, X, Plus } from 'lucide-react'
|
||||
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
|
||||
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
|
||||
import { CATEGORY_COLORS } from '../lib/constants'
|
||||
import type { AppMapping } from '../types'
|
||||
@@ -102,13 +102,13 @@ export default function Mappings() {
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors w-full'
|
||||
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
|
||||
const selectClass =
|
||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer w-full'
|
||||
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
|
||||
|
||||
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
|
||||
return (
|
||||
<tr className="bg-[#1a1d27]">
|
||||
<tr className="bg-white/[0.04]">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -162,7 +162,7 @@ export default function Mappings() {
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -177,7 +177,7 @@ export default function Mappings() {
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">App Mappings</h1>
|
||||
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingNew(true)
|
||||
@@ -185,7 +185,7 @@ export default function Mappings() {
|
||||
setNewForm(emptyForm)
|
||||
}}
|
||||
disabled={addingNew}
|
||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Rule
|
||||
@@ -194,33 +194,34 @@ export default function Mappings() {
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-[#94a3b8] text-sm py-12 text-center">Loading mappings...</div>
|
||||
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
|
||||
) : !mappings?.length && !addingNew ? (
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-12 text-center">
|
||||
<p className="text-[#94a3b8] text-sm mb-3">No mappings configured</p>
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
|
||||
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
|
||||
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingNew(true)
|
||||
setNewForm(emptyForm)
|
||||
}}
|
||||
className="text-indigo-400 hover:text-indigo-300 text-sm font-medium transition-colors"
|
||||
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
|
||||
>
|
||||
+ Add your first mapping rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 overflow-hidden">
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#161922] border-b border-white/5">
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Pattern</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Match Type</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Category</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Friendly Name</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium w-24">Actions</th>
|
||||
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
|
||||
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
|
||||
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
|
||||
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
|
||||
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
<tbody>
|
||||
{/* Add-new row */}
|
||||
{addingNew &&
|
||||
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
||||
@@ -232,9 +233,9 @@ export default function Mappings() {
|
||||
) : (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="bg-[#1a1d27] hover:bg-[#1e2230] transition-colors"
|
||||
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-white font-mono text-xs">{m.pattern}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
@@ -247,7 +248,7 @@ export default function Mappings() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
||||
@@ -255,21 +256,21 @@ export default function Mappings() {
|
||||
{m.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||
{m.friendlyName ?? '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleEditStart(m)}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:text-white hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(m.id)}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
||||
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -13,4 +13,22 @@ internal static partial class NativeMethods
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct LASTINPUTINFO
|
||||
{
|
||||
public uint cbSize;
|
||||
public uint dwTime;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
||||
|
||||
internal static TimeSpan GetIdleTime()
|
||||
{
|
||||
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
|
||||
if (!GetLastInputInfo(ref info))
|
||||
return TimeSpan.Zero;
|
||||
return TimeSpan.FromMilliseconds(Environment.TickCount64 - info.dwTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public class WindowWatcherOptions
|
||||
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
|
||||
public int PollIntervalMs { get; set; } = 2000;
|
||||
public int DebounceMs { get; set; } = 3000;
|
||||
public int IdleTimeoutMs { get; set; } = 300_000;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ public class Worker(
|
||||
private string _lastAppName = string.Empty;
|
||||
private string _lastWindowTitle = string.Empty;
|
||||
private DateTime _lastChangeTime = DateTime.MinValue;
|
||||
private bool _isIdle;
|
||||
private int? _pausedTaskId;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = options.Value;
|
||||
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
|
||||
config.PollIntervalMs, config.DebounceMs);
|
||||
logger.LogInformation(
|
||||
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
|
||||
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -67,6 +70,21 @@ public class Worker(
|
||||
_lastWindowTitle = windowTitle;
|
||||
_lastChangeTime = now;
|
||||
}
|
||||
|
||||
// Idle detection
|
||||
var idleTime = NativeMethods.GetIdleTime();
|
||||
if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
|
||||
{
|
||||
_isIdle = true;
|
||||
logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
|
||||
await PauseActiveTaskAsync(ct: stoppingToken);
|
||||
}
|
||||
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
|
||||
{
|
||||
_isIdle = false;
|
||||
logger.LogInformation("User returned from idle");
|
||||
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -108,4 +126,80 @@ public class Worker(
|
||||
logger.LogWarning(ex, "Failed to report context event to API");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PauseActiveTaskAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||
|
||||
// Get the active task
|
||||
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||
"/api/tasks/active", ct);
|
||||
|
||||
if (response?.Data is null)
|
||||
{
|
||||
logger.LogDebug("No active task to pause");
|
||||
return;
|
||||
}
|
||||
|
||||
_pausedTaskId = response.Data.Id;
|
||||
|
||||
// Pause it
|
||||
var pauseResponse = await client.PutAsJsonAsync(
|
||||
$"/api/tasks/{_pausedTaskId}/pause",
|
||||
new { note = "Auto-paused: idle timeout" }, ct);
|
||||
|
||||
if (pauseResponse.IsSuccessStatusCode)
|
||||
logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
|
||||
else
|
||||
logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to pause active task on idle");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
|
||||
{
|
||||
if (_pausedTaskId is null)
|
||||
return;
|
||||
|
||||
var taskId = _pausedTaskId.Value;
|
||||
_pausedTaskId = null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||
|
||||
// Check the task is still paused (user may have manually switched tasks)
|
||||
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||
$"/api/tasks/{taskId}", ct);
|
||||
|
||||
if (response?.Data is null || response.Data.Status != "Paused")
|
||||
{
|
||||
logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume it
|
||||
var resumeResponse = await client.PutAsJsonAsync(
|
||||
$"/api/tasks/{taskId}/resume",
|
||||
new { note = "Auto-resumed: user returned" }, ct);
|
||||
|
||||
if (resumeResponse.IsSuccessStatusCode)
|
||||
logger.LogInformation("Auto-resumed task {TaskId}", taskId);
|
||||
else
|
||||
logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal record ActiveTaskDto(int Id, string Status);
|
||||
|
||||
internal record ApiResponse<T>(bool Success, T? Data, string? Error);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"WindowWatcher": {
|
||||
"ApiBaseUrl": "http://localhost:5200",
|
||||
"PollIntervalMs": 2000,
|
||||
"DebounceMs": 3000
|
||||
"DebounceMs": 3000,
|
||||
"IdleTimeoutMs": 300000
|
||||
}
|
||||
}
|
||||
|
||||
52
docs/plans/2026-02-27-idle-detection-design.md
Normal file
52
docs/plans/2026-02-27-idle-detection-design.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Idle Detection for WindowWatcher
|
||||
|
||||
## Summary
|
||||
|
||||
Add idle detection to WindowWatcher so it automatically pauses the active TaskTracker task when the user is away, and resumes it when they return.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Detection method:** `GetLastInputInfo` Win32 API — returns time of last keyboard/mouse input
|
||||
- **Idle threshold:** Configurable via `IdleTimeoutMs` (default: 300,000ms = 5 minutes)
|
||||
- **Checked on existing poll cycle** — no new timers or threads
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
ACTIVE ──(idle > threshold)──► IDLE
|
||||
▲ │
|
||||
└──(input detected)──────────────┘
|
||||
```
|
||||
|
||||
### On Idle Transition
|
||||
|
||||
1. `GET /api/tasks/active` to find the current task
|
||||
2. `PUT /api/tasks/{id}/pause` with note "Auto-paused: idle timeout"
|
||||
3. Store paused task ID locally
|
||||
|
||||
### On Resume Transition
|
||||
|
||||
1. Check if stored task ID is still in Paused status
|
||||
2. If yes: `PUT /api/tasks/{id}/resume` with note "Auto-resumed: user returned"
|
||||
3. Clear stored task ID
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `NativeMethods.cs` | Add `GetLastInputInfo` + `LASTINPUTINFO` struct |
|
||||
| `Worker.cs` | Add idle state tracking, check idle time each poll, call pause/resume APIs |
|
||||
| `WindowWatcherOptions.cs` | Add `IdleTimeoutMs` (default: 300000) |
|
||||
| `appsettings.json` | Add `IdleTimeoutMs` setting |
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **No active task when idle fires:** Log, skip pause, don't store task ID
|
||||
- **Task manually changed while idle:** On resume, verify stored task is still paused before resuming
|
||||
- **API unreachable:** Log warning, retry on next poll cycle. Maintain idle state locally
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Session lock/unlock detection
|
||||
- New context event types
|
||||
- Tray menu UI changes
|
||||
263
docs/plans/2026-02-27-idle-detection-plan.md
Normal file
263
docs/plans/2026-02-27-idle-detection-plan.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Idle Detection Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Auto-pause the active TaskTracker task when the user is idle, and auto-resume it when they return.
|
||||
|
||||
**Architecture:** Add `GetLastInputInfo` P/Invoke to `NativeMethods.cs`, check idle time on every existing poll cycle in `Worker.cs`, and call the TaskTracker pause/resume API on state transitions. No new threads, timers, or services needed.
|
||||
|
||||
**Tech Stack:** .NET 10, Win32 `user32.dll` P/Invoke, existing `IHttpClientFactory`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add GetLastInputInfo to NativeMethods
|
||||
|
||||
**Files:**
|
||||
- Modify: `WindowWatcher/NativeMethods.cs`
|
||||
|
||||
**Step 1: Add the LASTINPUTINFO struct and GetLastInputInfo import**
|
||||
|
||||
Add the following to `NativeMethods.cs`:
|
||||
|
||||
```csharp
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct LASTINPUTINFO
|
||||
{
|
||||
public uint cbSize;
|
||||
public uint dwTime;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
||||
```
|
||||
|
||||
Note: `GetLastInputInfo` must use `DllImport`, not `LibraryImport`, because it takes a `ref` struct with `cbSize` that needs manual marshalling.
|
||||
|
||||
**Step 2: Add a helper method for getting idle time**
|
||||
|
||||
```csharp
|
||||
internal static TimeSpan GetIdleTime()
|
||||
{
|
||||
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
|
||||
if (!GetLastInputInfo(ref info))
|
||||
return TimeSpan.Zero;
|
||||
return TimeSpan.FromMilliseconds(Environment.TickCount64 - info.dwTime);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add WindowWatcher/NativeMethods.cs
|
||||
git commit -m "feat(watcher): add GetLastInputInfo P/Invoke for idle detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add IdleTimeoutMs to configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `WindowWatcher/WindowWatcherOptions.cs`
|
||||
- Modify: `WindowWatcher/appsettings.json`
|
||||
|
||||
**Step 1: Add IdleTimeoutMs property**
|
||||
|
||||
In `WindowWatcherOptions.cs`, add:
|
||||
|
||||
```csharp
|
||||
public int IdleTimeoutMs { get; set; } = 300_000;
|
||||
```
|
||||
|
||||
**Step 2: Add to appsettings.json**
|
||||
|
||||
Add `"IdleTimeoutMs": 300000` to the `"WindowWatcher"` section:
|
||||
|
||||
```json
|
||||
"WindowWatcher": {
|
||||
"ApiBaseUrl": "http://localhost:5200",
|
||||
"PollIntervalMs": 2000,
|
||||
"DebounceMs": 3000,
|
||||
"IdleTimeoutMs": 300000
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add WindowWatcher/WindowWatcherOptions.cs WindowWatcher/appsettings.json
|
||||
git commit -m "feat(watcher): add configurable IdleTimeoutMs setting (default 5 min)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add idle detection logic to Worker
|
||||
|
||||
**Files:**
|
||||
- Modify: `WindowWatcher/Worker.cs`
|
||||
|
||||
This is the main task. Add idle state tracking and API calls for pause/resume.
|
||||
|
||||
**Step 1: Add idle tracking fields**
|
||||
|
||||
Add these fields to the `Worker` class (after the existing `_lastChangeTime` field):
|
||||
|
||||
```csharp
|
||||
private bool _isIdle;
|
||||
private int? _pausedTaskId;
|
||||
```
|
||||
|
||||
**Step 2: Add idle check to the polling loop**
|
||||
|
||||
At the end of the `try` block in `ExecuteAsync` (after the window-change detection block, before the catch), add:
|
||||
|
||||
```csharp
|
||||
// Idle detection
|
||||
var idleTime = NativeMethods.GetIdleTime();
|
||||
if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
|
||||
{
|
||||
_isIdle = true;
|
||||
logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
|
||||
await PauseActiveTaskAsync(ct: stoppingToken);
|
||||
}
|
||||
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
|
||||
{
|
||||
_isIdle = false;
|
||||
logger.LogInformation("User returned from idle");
|
||||
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update the startup log to include idle timeout**
|
||||
|
||||
Change the existing `LogInformation` line to:
|
||||
|
||||
```csharp
|
||||
logger.LogInformation(
|
||||
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
|
||||
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
|
||||
```
|
||||
|
||||
**Step 4: Add PauseActiveTaskAsync method**
|
||||
|
||||
```csharp
|
||||
private async Task PauseActiveTaskAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||
|
||||
// Get the active task
|
||||
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||
"/api/tasks/active", ct);
|
||||
|
||||
if (response?.Data is null)
|
||||
{
|
||||
logger.LogDebug("No active task to pause");
|
||||
return;
|
||||
}
|
||||
|
||||
_pausedTaskId = response.Data.Id;
|
||||
|
||||
// Pause it
|
||||
var pauseResponse = await client.PutAsJsonAsync(
|
||||
$"/api/tasks/{_pausedTaskId}/pause",
|
||||
new { note = "Auto-paused: idle timeout" }, ct);
|
||||
|
||||
if (pauseResponse.IsSuccessStatusCode)
|
||||
logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
|
||||
else
|
||||
logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to pause active task on idle");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Add ResumeIdlePausedTaskAsync method**
|
||||
|
||||
```csharp
|
||||
private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
|
||||
{
|
||||
if (_pausedTaskId is null)
|
||||
return;
|
||||
|
||||
var taskId = _pausedTaskId.Value;
|
||||
_pausedTaskId = null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||
|
||||
// Check the task is still paused (user may have manually switched tasks)
|
||||
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
|
||||
$"/api/tasks/{taskId}", ct);
|
||||
|
||||
if (response?.Data is null || response.Data.Status != "Paused")
|
||||
{
|
||||
logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume it
|
||||
var resumeResponse = await client.PutAsJsonAsync(
|
||||
$"/api/tasks/{taskId}/resume",
|
||||
new { note = "Auto-resumed: user returned" }, ct);
|
||||
|
||||
if (resumeResponse.IsSuccessStatusCode)
|
||||
logger.LogInformation("Auto-resumed task {TaskId}", taskId);
|
||||
else
|
||||
logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Add the minimal DTO for deserializing API responses**
|
||||
|
||||
Add a simple record at the bottom of `Worker.cs` (file-scoped, internal):
|
||||
|
||||
```csharp
|
||||
internal record ActiveTaskDto(int Id, string Status);
|
||||
```
|
||||
|
||||
This is all we need to deserialize from the `ApiResponse<T>` wrapper — `System.Text.Json` will ignore extra properties.
|
||||
|
||||
**Step 7: Add the missing using for JSON deserialization**
|
||||
|
||||
Verify `using System.Net.Http.Json;` already exists (it does — line 2 of Worker.cs). No changes needed.
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add WindowWatcher/Worker.cs
|
||||
git commit -m "feat(watcher): add idle detection with auto-pause/resume"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Build and verify
|
||||
|
||||
**Step 1: Build the project**
|
||||
|
||||
```bash
|
||||
cd WindowWatcher && dotnet build
|
||||
```
|
||||
|
||||
Expected: Build succeeded with 0 errors.
|
||||
|
||||
**Step 2: Fix any build errors**
|
||||
|
||||
If there are errors, fix them.
|
||||
|
||||
**Step 3: Commit any fixes**
|
||||
|
||||
If fixes were needed:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix(watcher): fix build errors in idle detection"
|
||||
```
|
||||
Reference in New Issue
Block a user