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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"framer-motion": "^12.34.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -3195,6 +3196,33 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3469,7 +3497,6 @@
|
|||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -3571,7 +3598,6 @@
|
|||||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -3922,6 +3948,21 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"framer-motion": "^12.34.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
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 (
|
return (
|
||||||
<div
|
<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}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
@@ -104,7 +104,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -112,7 +112,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!title.trim() || createTask.isPending}
|
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
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
px-3 py-1.5 rounded-md transition-colors"
|
px-3 py-1.5 rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -62,17 +62,17 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
|||||||
{/* "All" chip */}
|
{/* "All" chip */}
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
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
|
!hasActiveFilters
|
||||||
? 'bg-indigo-500 text-white'
|
? 'bg-[var(--color-accent)] text-white'
|
||||||
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="w-px h-4 bg-white/10" />
|
<div className="w-px h-4 bg-white/[0.06]" />
|
||||||
|
|
||||||
{/* Category chips */}
|
{/* Category chips */}
|
||||||
{allCategories.map((cat) => {
|
{allCategories.map((cat) => {
|
||||||
@@ -83,8 +83,8 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
|||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => toggleCategory(cat)}
|
onClick={() => toggleCategory(cat)}
|
||||||
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 ${
|
||||||
isActive ? 'text-white' : 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
isActive ? 'text-white' : 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
style={
|
style={
|
||||||
isActive
|
isActive
|
||||||
@@ -108,15 +108,15 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="w-px h-4 bg-white/10" />
|
<div className="w-px h-4 bg-white/[0.06]" />
|
||||||
|
|
||||||
{/* Has subtasks chip */}
|
{/* Has subtasks chip */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleHasSubtasks}
|
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
|
filters.hasSubtasks
|
||||||
? 'bg-indigo-500 text-white'
|
? 'bg-[var(--color-accent)] text-white'
|
||||||
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
|
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ListTree size={12} />
|
<ListTree size={12} />
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoa
|
|||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeTask ? (
|
{activeTask ? (
|
||||||
<div className="rotate-2 scale-105">
|
<div className="rotate-1 scale-[1.03] opacity-90">
|
||||||
<TaskCard task={activeTask} onClick={() => {}} />
|
<TaskCard task={activeTask} onClick={() => {}} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -28,56 +28,56 @@ export default function KanbanColumn({
|
|||||||
const taskIds = tasks.map((t) => t.id)
|
const taskIds = tasks.map((t) => t.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col min-h-[300px]">
|
||||||
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'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Column header */}
|
{/* Column header */}
|
||||||
<div className="px-4 pt-4 pb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h2 className="text-sm font-semibold text-white">{label}</h2>
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
|
||||||
<span
|
{label}
|
||||||
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
|
</h2>
|
||||||
style={{
|
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||||
backgroundColor: color + '20',
|
|
||||||
color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tasks.length}
|
{tasks.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-0.5 rounded-full" style={{ backgroundColor: color }} />
|
<div className="h-[2px] rounded-full" style={{ backgroundColor: color }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards area */}
|
{/* Cards area */}
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
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}>
|
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
|
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Add task form / button (Pending column only) */}
|
{/* Add task (Pending column only) */}
|
||||||
{status === WorkTaskStatus.Pending && (
|
{status === WorkTaskStatus.Pending && (
|
||||||
<div className="px-3 pb-3">
|
<div className="mt-2">
|
||||||
{showForm ? (
|
{showForm ? (
|
||||||
<CreateTaskForm onClose={() => setShowForm(false)} />
|
<CreateTaskForm onClose={() => setShowForm(false)} />
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg
|
className="flex items-center gap-1.5 w-full py-2 rounded-lg
|
||||||
text-xs text-[#64748b] border border-dashed border-white/10
|
text-[11px] text-[var(--color-text-tertiary)]
|
||||||
hover:text-white hover:border-white/20 transition-all duration-200"
|
hover:text-[var(--color-text-secondary)] hover:bg-white/[0.02]
|
||||||
|
transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={13} />
|
||||||
Add Task
|
New task
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
import { LayoutGrid, BarChart3, Link, Plus, Search } from 'lucide-react'
|
||||||
import SearchBar from './SearchBar.tsx'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import SearchModal from './SearchModal.tsx'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/board', label: 'Board', icon: LayoutGrid },
|
{ to: '/board', label: 'Board', icon: LayoutGrid },
|
||||||
@@ -10,60 +10,99 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-[#0f1117] text-white overflow-hidden">
|
<div className="flex flex-col h-screen bg-[var(--color-page)] text-[var(--color-text-primary)] overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Top navigation bar */}
|
||||||
<aside
|
<header className="flex items-center h-12 px-4 border-b border-[var(--color-border)] shrink-0 bg-[var(--color-page)]">
|
||||||
className="flex flex-col justify-between shrink-0 transition-all duration-200"
|
{/* Logo */}
|
||||||
style={{
|
<div className="flex items-center gap-2 mr-8">
|
||||||
width: collapsed ? 60 : 200,
|
<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">
|
||||||
background: 'linear-gradient(180deg, #0f1117 0%, #161922 100%)',
|
<span className="text-[10px] font-bold text-white">T</span>
|
||||||
}}
|
</div>
|
||||||
>
|
<span className="text-sm font-semibold tracking-tight">TaskTracker</span>
|
||||||
<nav className="flex flex-col gap-1 mt-4 px-2">
|
</div>
|
||||||
|
|
||||||
|
{/* Nav tabs */}
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
{navItems.map(({ to, label, icon: Icon }) => (
|
{navItems.map(({ to, label, icon: Icon }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) =>
|
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
|
isActive
|
||||||
? 'bg-[#6366f1] text-white'
|
? 'text-white bg-white/[0.08]'
|
||||||
: 'text-[#94a3b8] hover:text-white hover:bg-white/5'
|
: 'text-[var(--color-text-secondary)] hover:text-white hover:bg-white/[0.04]'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={20} className="shrink-0" />
|
<Icon size={15} />
|
||||||
{!collapsed && <span>{label}</span>}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setSearchOpen(true)}
|
||||||
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"
|
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"
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main area */}
|
{/* New task button */}
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<button
|
||||||
{/* Top bar */}
|
onClick={() => {
|
||||||
<header className="flex items-center justify-between h-14 px-6 border-b border-white/5 shrink-0">
|
navigate('/board')
|
||||||
<h1 className="text-lg font-semibold tracking-tight">TaskTracker</h1>
|
setShowCreateHint(true)
|
||||||
<SearchBar onSelect={(taskId) => navigate(`/board?task=${taskId}`)} />
|
setTimeout(() => setShowCreateHint(false), 100)
|
||||||
</header>
|
}}
|
||||||
|
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 */}
|
{/* Content */}
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-5">
|
||||||
<Outlet />
|
<Outlet context={{ showCreateHint }} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
{/* Search modal */}
|
||||||
|
{searchOpen && (
|
||||||
|
<SearchModal
|
||||||
|
onSelect={(taskId) => {
|
||||||
|
setSearchOpen(false)
|
||||||
|
navigate(`/board?task=${taskId}`)
|
||||||
|
}}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface NotesListProps {
|
|||||||
notes: TaskNote[]
|
notes: TaskNote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTE_TYPE_CONFIG: Record<string, { label: string; color: string }> = {
|
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
|
||||||
[NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' },
|
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||||
[NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' },
|
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||||
[NoteType.General]: { label: 'General', color: '#64748b' },
|
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
@@ -68,12 +68,12 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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
|
Notes
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInput(true)}
|
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} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,25 +86,21 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
<div key={note.id} className="text-sm">
|
<div key={note.id} className="text-sm">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded-full"
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
|
||||||
style={{
|
|
||||||
backgroundColor: typeConfig.color + '20',
|
|
||||||
color: typeConfig.color,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{typeConfig.label}
|
{typeConfig.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-[#64748b]">
|
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||||
{formatRelativeTime(note.createdAt)}
|
{formatRelativeTime(note.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#c4c9d4] leading-relaxed">{note.content}</p>
|
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{sortedNotes.length === 0 && !showInput && (
|
{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 && (
|
{showInput && (
|
||||||
@@ -121,7 +117,7 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Add a note..."
|
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>
|
</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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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
|
Subtasks
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInput(true)}
|
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} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -67,13 +67,13 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
onClick={() => handleToggle(subtask)}
|
onClick={() => handleToggle(subtask)}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{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
|
<span
|
||||||
className={`text-sm ${
|
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}
|
{subtask.title}
|
||||||
@@ -84,7 +84,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
|
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<div className="flex items-center gap-2 py-1.5 px-1">
|
<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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -98,7 +98,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="New subtask..."
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { CheckSquare, Clock } from 'lucide-react'
|
import { Clock } from 'lucide-react'
|
||||||
import { WorkTaskStatus } from '../types/index.ts'
|
import { WorkTaskStatus } from '../types/index.ts'
|
||||||
import type { WorkTask } from '../types/index.ts'
|
import type { WorkTask } from '../types/index.ts'
|
||||||
import { CATEGORY_COLORS } from '../lib/constants.ts'
|
import { CATEGORY_COLORS } from '../lib/constants.ts'
|
||||||
@@ -36,7 +36,7 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
|||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.4 : 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
|
||||||
@@ -48,14 +48,6 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
|||||||
).length ?? 0
|
).length ?? 0
|
||||||
const totalSubTasks = task.subTasks?.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -64,62 +56,59 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
|
|||||||
{...listeners}
|
{...listeners}
|
||||||
onClick={() => onClick(task.id)}
|
onClick={() => onClick(task.id)}
|
||||||
className={`
|
className={`
|
||||||
relative rounded-lg cursor-grab active:cursor-grabbing
|
card-glow rounded-xl cursor-grab active:cursor-grabbing
|
||||||
bg-[#1a1d27] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-indigo-500/5
|
bg-[var(--color-surface)] border transition-all duration-200
|
||||||
transition-all duration-200
|
hover:-translate-y-0.5
|
||||||
${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'}
|
${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="px-3.5 py-3">
|
||||||
<div
|
{/* Title row */}
|
||||||
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
|
<div className="flex items-start gap-2 mb-1.5">
|
||||||
style={{ backgroundColor: categoryColor }}
|
{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">
|
{/* Meta row */}
|
||||||
{/* Title */}
|
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-secondary)]">
|
||||||
<p className="text-sm font-medium text-white leading-snug mb-2 truncate">
|
{task.category && (
|
||||||
{task.title}
|
<span className="flex items-center gap-1">
|
||||||
</p>
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: categoryColor }}
|
||||||
|
/>
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Category badge */}
|
{elapsed && (
|
||||||
{task.category && (
|
<span className="flex items-center gap-1">
|
||||||
<span
|
<Clock size={10} />
|
||||||
className="inline-block text-[11px] font-medium px-2 py-0.5 rounded-full mb-2"
|
{elapsed}
|
||||||
style={{
|
</span>
|
||||||
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>
|
|
||||||
|
|
||||||
{totalSubTasks > 0 && (
|
{totalSubTasks > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="ml-auto flex items-center gap-1">
|
||||||
<CheckSquare size={12} />
|
{Array.from({ length: totalSubTasks }, (_, i) => (
|
||||||
{completedSubTasks}/{totalSubTasks}
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
import { X, Loader2 } from 'lucide-react'
|
import { X, Loader2 } from 'lucide-react'
|
||||||
import { WorkTaskStatus } from '../types/index.ts'
|
import { WorkTaskStatus } from '../types/index.ts'
|
||||||
import {
|
import {
|
||||||
@@ -40,9 +41,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
const completeTask = useCompleteTask()
|
const completeTask = useCompleteTask()
|
||||||
const abandonTask = useAbandonTask()
|
const abandonTask = useAbandonTask()
|
||||||
|
|
||||||
// Slide-in animation state
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
|
|
||||||
// Inline editing states
|
// Inline editing states
|
||||||
const [editingTitle, setEditingTitle] = useState(false)
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
const [titleValue, setTitleValue] = useState('')
|
const [titleValue, setTitleValue] = useState('')
|
||||||
@@ -58,11 +56,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
const categoryInputRef = useRef<HTMLInputElement>(null)
|
const categoryInputRef = useRef<HTMLInputElement>(null)
|
||||||
const estimateInputRef = useRef<HTMLInputElement>(null)
|
const estimateInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Trigger slide-in
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => setVisible(true))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Escape key handler
|
// Escape key handler
|
||||||
const handleEscape = useCallback(
|
const handleEscape = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
@@ -75,10 +68,10 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
setEditingEstimate(false)
|
setEditingEstimate(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleClose()
|
onClose()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps
|
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,11 +93,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
if (editingEstimate) estimateInputRef.current?.focus()
|
if (editingEstimate) estimateInputRef.current?.focus()
|
||||||
}, [editingEstimate])
|
}, [editingEstimate])
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setVisible(false)
|
|
||||||
setTimeout(onClose, 200) // wait for slide-out animation
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Save handlers ---
|
// --- Save handlers ---
|
||||||
function saveTitle() {
|
function saveTitle() {
|
||||||
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
|
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
|
||||||
@@ -153,22 +141,25 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div
|
<motion.div
|
||||||
className={`fixed inset-0 z-40 transition-opacity duration-200 ${
|
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||||
visible ? 'bg-black/50' : 'bg-black/0'
|
initial={{ opacity: 0 }}
|
||||||
}`}
|
animate={{ opacity: 1 }}
|
||||||
onClick={handleClose}
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<motion.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 ${
|
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"
|
||||||
visible ? 'translate-x-0' : 'translate-x-full'
|
initial={{ x: '100%' }}
|
||||||
}`}
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||||
>
|
>
|
||||||
{isLoading || !task ? (
|
{isLoading || !task ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -176,47 +167,47 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-5 pb-4">
|
<div className="p-5 pb-4">
|
||||||
{/* Close button */}
|
{/* Title row with close button inline */}
|
||||||
<div className="flex justify-end mb-3">
|
<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
|
<button
|
||||||
onClick={handleClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-white/10 text-[#64748b] hover:text-white transition-colors"
|
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} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Status badge + Category */}
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
{statusConfig && (
|
{statusConfig && (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
backgroundColor: statusConfig.color + '20',
|
backgroundColor: statusConfig.color + '20',
|
||||||
color: statusConfig.color,
|
color: statusConfig.color,
|
||||||
@@ -238,11 +229,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
if (e.key === 'Escape') setEditingCategory(false)
|
if (e.key === 'Escape') setEditingCategory(false)
|
||||||
}}
|
}}
|
||||||
placeholder="Category..."
|
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
|
<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={() => {
|
onClick={() => {
|
||||||
setCategoryValue(task.category ?? '')
|
setCategoryValue(task.category ?? '')
|
||||||
setEditingCategory(true)
|
setEditingCategory(true)
|
||||||
@@ -254,11 +245,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#2a2d37]" />
|
<div className="border-t border-[var(--color-border)]" />
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="p-5">
|
<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
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
{editingDesc ? (
|
{editingDesc ? (
|
||||||
@@ -274,13 +265,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={4}
|
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..."
|
placeholder="Add a description..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
|
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={() => {
|
onClick={() => {
|
||||||
setDescValue(task.description ?? '')
|
setDescValue(task.description ?? '')
|
||||||
@@ -292,23 +283,23 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#2a2d37]" />
|
<div className="border-t border-[var(--color-border)]" />
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div className="p-5">
|
<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
|
Time
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] text-[#64748b] block mb-1">Elapsed</span>
|
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
|
||||||
<span className="text-sm text-white font-medium">
|
<span className="text-sm text-[var(--color-text-primary)] font-medium">
|
||||||
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
|
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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 ? (
|
{editingEstimate ? (
|
||||||
<input
|
<input
|
||||||
ref={estimateInputRef}
|
ref={estimateInputRef}
|
||||||
@@ -321,11 +312,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
if (e.key === 'Escape') setEditingEstimate(false)
|
if (e.key === 'Escape') setEditingEstimate(false)
|
||||||
}}
|
}}
|
||||||
placeholder="min"
|
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
|
<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={() => {
|
onClick={() => {
|
||||||
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
|
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
|
||||||
setEditingEstimate(true)
|
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-2 w-full bg-white/5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${
|
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)}%` }}
|
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
@@ -350,14 +343,14 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#2a2d37]" />
|
<div className="border-t border-[var(--color-border)]" />
|
||||||
|
|
||||||
{/* Subtasks */}
|
{/* Subtasks */}
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
|
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#2a2d37]" />
|
<div className="border-t border-[var(--color-border)]" />
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@@ -367,13 +360,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
|
|
||||||
{/* Action buttons - fixed at bottom */}
|
{/* Action buttons - fixed at bottom */}
|
||||||
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
|
{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 && (
|
{task.status === WorkTaskStatus.Pending && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => startTask.mutate(taskId)}
|
onClick={() => startTask.mutate(taskId)}
|
||||||
disabled={startTask.isPending}
|
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
|
Start
|
||||||
</button>
|
</button>
|
||||||
@@ -418,7 +411,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
|
|||||||
<button
|
<button
|
||||||
onClick={() => resumeTask.mutate({ id: taskId })}
|
onClick={() => resumeTask.mutate({ id: taskId })}
|
||||||
disabled={resumeTask.isPending}
|
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
|
Resume
|
||||||
</button>
|
</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) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
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...
|
Loading activity...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
if (sortedEvents.length === 0) {
|
if (sortedEvents.length === 0) {
|
||||||
return (
|
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.
|
No activity events for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -90,30 +90,36 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="divide-y divide-white/5">
|
<div className="relative">
|
||||||
{visibleEvents.map((evt) => {
|
{visibleEvents.map((evt, idx) => {
|
||||||
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
||||||
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
||||||
const detail = evt.url || evt.windowTitle || ''
|
const detail = evt.url || evt.windowTitle || ''
|
||||||
|
const isLast = idx === visibleEvents.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={evt.id} className="flex items-start gap-3 py-3">
|
<div key={evt.id} className="flex items-start gap-3 relative">
|
||||||
{/* Category dot */}
|
{/* Timeline connector + dot */}
|
||||||
<span
|
<div className="flex flex-col items-center shrink-0">
|
||||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
<span
|
||||||
style={{ backgroundColor: color }}
|
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 */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<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)}
|
{formatTimestamp(evt.timestamp)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{detail && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +130,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
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)
|
Load more ({sortedEvents.length - visibleCount} remaining)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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...
|
Loading category breakdown...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return (
|
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.
|
No category data available.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-white text-sm font-medium">{d.name}</div>
|
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
|
||||||
<div className="text-[#94a3b8] text-xs mt-0.5">
|
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
|
||||||
{d.count} events ({d.percentage}%)
|
{d.count} events ({d.percentage}%)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
{/* Name + bar + stats */}
|
{/* Name + bar + stats */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
|
||||||
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
|
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
|
||||||
{cat.count} ({cat.percentage}%)
|
{cat.count} ({cat.percentage}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* 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
|
<div
|
||||||
className="h-full rounded-full transition-all duration-300"
|
className="h-full rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (eventsLoading || mappingsLoading) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
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...
|
Loading timeline...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (buckets.length === 0) {
|
if (buckets.length === 0) {
|
||||||
return (
|
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.
|
No activity data for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={{ stroke: '#1e293b' }}
|
axisLine={{ stroke: 'var(--color-border)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
@@ -169,8 +169,8 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
@@ -181,14 +181,14 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
|
<div className="text-[var(--color-text-secondary)] 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-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
||||||
{d.count} events
|
{d.count} events
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,22 +2,81 @@
|
|||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', system-ui, sans-serif;
|
--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 {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
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% {
|
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 {
|
.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 {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
@@ -27,10 +86,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #2a2d37;
|
background: #1a1b26;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-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 = [
|
export const COLUMN_CONFIG = [
|
||||||
{ status: 'Pending' as const, label: 'Pending', color: '#94a3b8' },
|
{ status: 'Pending' as const, label: 'Pending', color: '#64748b' },
|
||||||
{ status: 'Active' as const, label: 'Active', color: '#06b6d4' },
|
{ status: 'Active' as const, label: 'Active', color: '#3b82f6' },
|
||||||
{ status: 'Paused' as const, label: 'Paused', color: '#f59e0b' },
|
{ status: 'Paused' as const, label: 'Paused', color: '#eab308' },
|
||||||
{ status: 'Completed' as const, label: 'Completed', color: '#10b981' },
|
{ status: 'Completed' as const, label: 'Completed', color: '#22c55e' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const CATEGORY_COLORS: Record<string, string> = {
|
export const CATEGORY_COLORS: Record<string, string> = {
|
||||||
@@ -12,5 +12,10 @@ export const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
DevOps: '#f97316',
|
DevOps: '#f97316',
|
||||||
Documentation: '#14b8a6',
|
Documentation: '#14b8a6',
|
||||||
Design: '#ec4899',
|
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">
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
{/* Header + Filters */}
|
{/* Header + Filters */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{/* Time range dropdown */}
|
{/* Time range dropdown */}
|
||||||
<select
|
<select
|
||||||
value={minutes}
|
value={minutes}
|
||||||
onChange={(e) => setMinutes(Number(e.target.value))}
|
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) => (
|
{TIME_RANGES.map((r) => (
|
||||||
<option key={r.minutes} value={r.minutes}>
|
<option key={r.minutes} value={r.minutes}>
|
||||||
@@ -39,7 +39,7 @@ export default function Analytics() {
|
|||||||
<select
|
<select
|
||||||
value={taskId ?? ''}
|
value={taskId ?? ''}
|
||||||
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
|
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>
|
<option value="">All Tasks</option>
|
||||||
{tasks?.map((t) => (
|
{tasks?.map((t) => (
|
||||||
@@ -51,32 +51,69 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Timeline */}
|
||||||
<section>
|
<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
|
Activity Timeline
|
||||||
</h2>
|
</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} />
|
<Timeline minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<section>
|
<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
|
Category Breakdown
|
||||||
</h2>
|
</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} />
|
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity Feed */}
|
{/* Activity Feed */}
|
||||||
<section>
|
<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
|
Recent Activity
|
||||||
</h2>
|
</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} />
|
<ActivityFeed minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
|
||||||
import { CATEGORY_COLORS } from '../lib/constants'
|
import { CATEGORY_COLORS } from '../lib/constants'
|
||||||
import type { AppMapping } from '../types'
|
import type { AppMapping } from '../types'
|
||||||
@@ -102,13 +102,13 @@ export default function Mappings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
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 =
|
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) {
|
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
|
||||||
return (
|
return (
|
||||||
<tr className="bg-[#1a1d27]">
|
<tr className="bg-white/[0.04]">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -162,7 +162,7 @@ export default function Mappings() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
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"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -177,7 +177,7 @@ export default function Mappings() {
|
|||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddingNew(true)
|
setAddingNew(true)
|
||||||
@@ -185,7 +185,7 @@ export default function Mappings() {
|
|||||||
setNewForm(emptyForm)
|
setNewForm(emptyForm)
|
||||||
}}
|
}}
|
||||||
disabled={addingNew}
|
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} />
|
<Plus size={16} />
|
||||||
Add Rule
|
Add Rule
|
||||||
@@ -194,33 +194,34 @@ export default function Mappings() {
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{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 ? (
|
) : !mappings?.length && !addingNew ? (
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-12 text-center">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
|
||||||
<p className="text-[#94a3b8] text-sm mb-3">No mappings configured</p>
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddingNew(true)
|
setAddingNew(true)
|
||||||
setNewForm(emptyForm)
|
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
|
+ Add your first mapping rule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#161922] border-b border-white/5">
|
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Pattern</th>
|
<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-[#94a3b8] 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">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-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] 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-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
|
||||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium w-24">Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody>
|
||||||
{/* Add-new row */}
|
{/* Add-new row */}
|
||||||
{addingNew &&
|
{addingNew &&
|
||||||
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
||||||
@@ -232,9 +233,9 @@ export default function Mappings() {
|
|||||||
) : (
|
) : (
|
||||||
<tr
|
<tr
|
||||||
key={m.id}
|
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">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
@@ -247,7 +248,7 @@ export default function Mappings() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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
|
<span
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
||||||
@@ -255,21 +256,21 @@ export default function Mappings() {
|
|||||||
{m.category}
|
{m.category}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[#94a3b8]">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{m.friendlyName ?? '\u2014'}
|
{m.friendlyName ?? '\u2014'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(m)}
|
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"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(m.id)}
|
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"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -13,4 +13,22 @@ internal static partial class NativeMethods
|
|||||||
|
|
||||||
[LibraryImport("user32.dll", SetLastError = true)]
|
[LibraryImport("user32.dll", SetLastError = true)]
|
||||||
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
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 string ApiBaseUrl { get; set; } = "http://localhost:5200";
|
||||||
public int PollIntervalMs { get; set; } = 2000;
|
public int PollIntervalMs { get; set; } = 2000;
|
||||||
public int DebounceMs { get; set; } = 3000;
|
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 _lastAppName = string.Empty;
|
||||||
private string _lastWindowTitle = string.Empty;
|
private string _lastWindowTitle = string.Empty;
|
||||||
private DateTime _lastChangeTime = DateTime.MinValue;
|
private DateTime _lastChangeTime = DateTime.MinValue;
|
||||||
|
private bool _isIdle;
|
||||||
|
private int? _pausedTaskId;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var config = options.Value;
|
var config = options.Value;
|
||||||
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
|
logger.LogInformation(
|
||||||
config.PollIntervalMs, config.DebounceMs);
|
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
|
||||||
|
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -67,6 +70,21 @@ public class Worker(
|
|||||||
_lastWindowTitle = windowTitle;
|
_lastWindowTitle = windowTitle;
|
||||||
_lastChangeTime = now;
|
_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)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -108,4 +126,80 @@ public class Worker(
|
|||||||
logger.LogWarning(ex, "Failed to report context event to API");
|
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": {
|
"WindowWatcher": {
|
||||||
"ApiBaseUrl": "http://localhost:5200",
|
"ApiBaseUrl": "http://localhost:5200",
|
||||||
"PollIntervalMs": 2000,
|
"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