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:
2026-02-27 00:17:29 -05:00
27 changed files with 1114 additions and 500 deletions

View File

@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>tasktracker-web</title>
<title>TaskTracker</title>
</head>
<body>
<div id="root"></div>

View File

@@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -3195,6 +3196,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.3",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3469,7 +3497,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -3571,7 +3598,6 @@
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3922,6 +3948,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@@ -48,11 +48,11 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
}
const inputClass =
'w-full rounded-md bg-[#0f1117] text-white text-sm px-3 py-2 border border-white/10 placeholder-[#64748b] focus:outline-none focus:ring-2 focus:ring-indigo-500/60 focus:border-transparent transition-colors'
'w-full rounded-md bg-[var(--color-page)] text-white text-sm px-3 py-2 border border-[var(--color-border)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/60 focus:border-transparent transition-colors'
return (
<div
className="rounded-lg bg-[#1a1d27] p-3 space-y-3"
className="rounded-lg bg-[var(--color-surface)] p-3 space-y-3"
onKeyDown={handleKeyDown}
>
{/* Title */}
@@ -104,7 +104,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
<button
type="button"
onClick={onClose}
className="text-xs text-[#64748b] hover:text-white transition-colors px-2 py-1"
className="text-xs text-[var(--color-text-secondary)] hover:text-white transition-colors px-2 py-1"
>
Cancel
</button>
@@ -112,7 +112,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
type="button"
onClick={handleSubmit}
disabled={!title.trim() || createTask.isPending}
className="flex items-center gap-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-500
className="flex items-center gap-1.5 text-xs font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110
disabled:opacity-40 disabled:cursor-not-allowed
px-3 py-1.5 rounded-md transition-colors"
>

View File

@@ -62,17 +62,17 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
{/* "All" chip */}
<button
onClick={clearAll}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
!hasActiveFilters
? 'bg-indigo-500 text-white'
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
All
</button>
{/* Divider */}
<div className="w-px h-4 bg-white/10" />
<div className="w-px h-4 bg-white/[0.06]" />
{/* Category chips */}
{allCategories.map((cat) => {
@@ -83,8 +83,8 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
<button
key={cat}
onClick={() => toggleCategory(cat)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
isActive ? 'text-white' : 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
isActive ? 'text-white' : 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
style={
isActive
@@ -108,15 +108,15 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar
})}
{/* Divider */}
<div className="w-px h-4 bg-white/10" />
<div className="w-px h-4 bg-white/[0.06]" />
{/* Has subtasks chip */}
<button
onClick={toggleHasSubtasks}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
filters.hasSubtasks
? 'bg-indigo-500 text-white'
: 'bg-[#2a2d37] text-[#94a3b8] hover:text-white'
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
<ListTree size={12} />

View File

@@ -143,7 +143,7 @@ export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoa
<DragOverlay>
{activeTask ? (
<div className="rotate-2 scale-105">
<div className="rotate-1 scale-[1.03] opacity-90">
<TaskCard task={activeTask} onClick={() => {}} />
</div>
) : null}

View File

@@ -28,56 +28,56 @@ export default function KanbanColumn({
const taskIds = tasks.map((t) => t.id)
return (
<div
className={`
flex flex-col min-h-[400px] rounded-xl
bg-white/[0.02] border transition-all duration-200
${isOver ? 'border-indigo-500/30 bg-white/[0.04] shadow-lg shadow-indigo-500/5' : 'border-white/5'}
`}
>
<div className="flex flex-col min-h-[300px]">
{/* Column header */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-white">{label}</h2>
<span
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: color + '20',
color,
}}
>
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
{label}
</h2>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{tasks.length}
</span>
</div>
<div className="h-0.5 rounded-full" style={{ backgroundColor: color }} />
<div className="h-[2px] rounded-full" style={{ backgroundColor: color }} />
</div>
{/* Cards area */}
<div
ref={setNodeRef}
className="flex-1 flex flex-col gap-2 px-3 pb-3 overflow-y-auto"
className={`flex-1 flex flex-col gap-2 rounded-lg transition-colors duration-200 py-1 ${
isOver ? 'bg-white/[0.02]' : ''
}`}
>
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</SortableContext>
{/* Empty state */}
{tasks.length === 0 && !showForm && (
<div className="flex-1 flex items-center justify-center min-h-[80px] rounded-lg border border-dashed border-white/[0.06]">
<span className="text-[11px] text-[var(--color-text-tertiary)]">No tasks</span>
</div>
)}
</div>
{/* Add task form / button (Pending column only) */}
{/* Add task (Pending column only) */}
{status === WorkTaskStatus.Pending && (
<div className="px-3 pb-3">
<div className="mt-2">
{showForm ? (
<CreateTaskForm onClose={() => setShowForm(false)} />
) : (
<button
onClick={() => setShowForm(true)}
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg
text-xs text-[#64748b] border border-dashed border-white/10
hover:text-white hover:border-white/20 transition-all duration-200"
className="flex items-center gap-1.5 w-full py-2 rounded-lg
text-[11px] text-[var(--color-text-tertiary)]
hover:text-[var(--color-text-secondary)] hover:bg-white/[0.02]
transition-colors"
>
<Plus size={14} />
Add Task
<Plus size={13} />
New task
</button>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import SearchBar from './SearchBar.tsx'
import { LayoutGrid, BarChart3, Link, Plus, Search } from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import SearchModal from './SearchModal.tsx'
const navItems = [
{ to: '/board', label: 'Board', icon: LayoutGrid },
@@ -10,60 +10,99 @@ const navItems = [
]
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const [searchOpen, setSearchOpen] = useState(false)
const [showCreateHint, setShowCreateHint] = useState(false)
// Global Cmd+K handler
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setSearchOpen(true)
}
}, [])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [handleGlobalKeyDown])
return (
<div className="flex h-screen bg-[#0f1117] text-white overflow-hidden">
{/* Sidebar */}
<aside
className="flex flex-col justify-between shrink-0 transition-all duration-200"
style={{
width: collapsed ? 60 : 200,
background: 'linear-gradient(180deg, #0f1117 0%, #161922 100%)',
}}
>
<nav className="flex flex-col gap-1 mt-4 px-2">
<div className="flex flex-col h-screen bg-[var(--color-page)] text-[var(--color-text-primary)] overflow-hidden">
{/* Top navigation bar */}
<header className="flex items-center h-12 px-4 border-b border-[var(--color-border)] shrink-0 bg-[var(--color-page)]">
{/* Logo */}
<div className="flex items-center gap-2 mr-8">
<div className="w-5 h-5 rounded bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent-end)] flex items-center justify-center">
<span className="text-[10px] font-bold text-white">T</span>
</div>
<span className="text-sm font-semibold tracking-tight">TaskTracker</span>
</div>
{/* Nav tabs */}
<nav className="flex items-center gap-1">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors ${
isActive
? 'bg-[#6366f1] text-white'
: 'text-[#94a3b8] hover:text-white hover:bg-white/5'
? 'text-white bg-white/[0.08]'
: 'text-[var(--color-text-secondary)] hover:text-white hover:bg-white/[0.04]'
}`
}
>
<Icon size={20} className="shrink-0" />
{!collapsed && <span>{label}</span>}
<Icon size={15} />
{label}
</NavLink>
))}
</nav>
{/* Spacer */}
<div className="flex-1" />
{/* Search trigger */}
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-center p-3 mb-2 mx-2 rounded-lg text-[#94a3b8] hover:text-white hover:bg-white/5 transition-all duration-200"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 h-7 px-2.5 rounded-md text-[12px] text-[var(--color-text-secondary)] bg-white/[0.04] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] transition-colors mr-2"
>
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
<Search size={13} />
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1 py-0.5 rounded">
Ctrl K
</kbd>
</button>
</aside>
{/* Main area */}
<div className="flex flex-col flex-1 min-w-0">
{/* Top bar */}
<header className="flex items-center justify-between h-14 px-6 border-b border-white/5 shrink-0">
<h1 className="text-lg font-semibold tracking-tight">TaskTracker</h1>
<SearchBar onSelect={(taskId) => navigate(`/board?task=${taskId}`)} />
</header>
{/* New task button */}
<button
onClick={() => {
navigate('/board')
setShowCreateHint(true)
setTimeout(() => setShowCreateHint(false), 100)
}}
className="flex items-center gap-1 h-7 px-2.5 rounded-md text-[12px] font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 transition-all"
>
<Plus size={14} />
<span className="hidden sm:inline">New Task</span>
</button>
</header>
{/* Content */}
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
{/* Content */}
<main className="flex-1 overflow-auto p-5">
<Outlet context={{ showCreateHint }} />
</main>
{/* Search modal */}
{searchOpen && (
<SearchModal
onSelect={(taskId) => {
setSearchOpen(false)
navigate(`/board?task=${taskId}`)
}}
onClose={() => setSearchOpen(false)}
/>
)}
</div>
)
}

View File

@@ -9,10 +9,10 @@ interface NotesListProps {
notes: TaskNote[]
}
const NOTE_TYPE_CONFIG: Record<string, { label: string; color: string }> = {
[NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' },
[NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' },
[NoteType.General]: { label: 'General', color: '#64748b' },
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
}
function formatRelativeTime(dateStr: string): string {
@@ -68,12 +68,12 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Notes
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
@@ -86,25 +86,21 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
<div key={note.id} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span
className="text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: typeConfig.color + '20',
color: typeConfig.color,
}}
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
>
{typeConfig.label}
</span>
<span className="text-[11px] text-[#64748b]">
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{formatRelativeTime(note.createdAt)}
</span>
</div>
<p className="text-[#c4c9d4] leading-relaxed">{note.content}</p>
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
</div>
)
})}
{sortedNotes.length === 0 && !showInput && (
<p className="text-sm text-[#64748b] italic">No notes yet</p>
<p className="text-sm text-[var(--color-text-secondary)] italic">No notes yet</p>
)}
{showInput && (
@@ -121,7 +117,7 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
}
}}
placeholder="Add a note..."
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
)}
</div>

View File

@@ -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>
)
}

View 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">&uarr;&darr;</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">&crarr;</kbd>
Open
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -46,12 +46,12 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Subtasks
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
@@ -67,13 +67,13 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
onClick={() => handleToggle(subtask)}
>
{isCompleted ? (
<CheckSquare size={16} className="text-emerald-400 flex-shrink-0" />
<CheckSquare size={16} className="text-[var(--color-status-completed)] flex-shrink-0" />
) : (
<Square size={16} className="text-[#64748b] group-hover:text-white flex-shrink-0" />
<Square size={16} className="text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] flex-shrink-0" />
)}
<span
className={`text-sm ${
isCompleted ? 'line-through text-[#64748b]' : 'text-white'
isCompleted ? 'line-through text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)]'
}`}
>
{subtask.title}
@@ -84,7 +84,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
{showInput && (
<div className="flex items-center gap-2 py-1.5 px-1">
<Square size={16} className="text-[#64748b] flex-shrink-0" />
<Square size={16} className="text-[var(--color-text-secondary)] flex-shrink-0" />
<input
ref={inputRef}
type="text"
@@ -98,7 +98,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
}
}}
placeholder="New subtask..."
className="flex-1 bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
className="flex-1 bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { CheckSquare, Clock } from 'lucide-react'
import { Clock } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
@@ -36,7 +36,7 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
opacity: isDragging ? 0.4 : 1,
}
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
@@ -48,14 +48,6 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
).length ?? 0
const totalSubTasks = task.subTasks?.length ?? 0
let progressPercent: number | null = null
if (task.estimatedMinutes && task.startedAt) {
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const elapsedMins = (end - start) / 60_000
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
}
return (
<div
ref={setNodeRef}
@@ -64,62 +56,59 @@ export default function TaskCard({ task, onClick }: TaskCardProps) {
{...listeners}
onClick={() => onClick(task.id)}
className={`
relative rounded-lg cursor-grab active:cursor-grabbing
bg-[#1a1d27] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-indigo-500/5
transition-all duration-200
${isActive ? 'ring-1 ring-cyan-400/60 shadow-[0_0_12px_rgba(6,182,212,0.25)] animate-pulse-glow' : 'shadow-md shadow-black/20'}
card-glow rounded-xl cursor-grab active:cursor-grabbing
bg-[var(--color-surface)] border transition-all duration-200
hover:-translate-y-0.5
${isActive
? 'border-[var(--color-status-active)]/30 animate-pulse-glow'
: 'border-[var(--color-border)] hover:border-[var(--color-border-hover)]'
}
${isDragging ? 'shadow-xl shadow-black/40' : ''}
`}
>
{/* Category left border */}
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
style={{ backgroundColor: categoryColor }}
/>
<div className="px-3.5 py-3">
{/* Title row */}
<div className="flex items-start gap-2 mb-1.5">
{isActive && (
<span className="shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full bg-[var(--color-status-active)] animate-live-dot" />
)}
<p className="text-[13px] font-medium text-[var(--color-text-primary)] leading-snug flex-1">
{task.title}
</p>
</div>
<div className="pl-4 pr-3 py-3">
{/* Title */}
<p className="text-sm font-medium text-white leading-snug mb-2 truncate">
{task.title}
</p>
{/* Meta row */}
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-secondary)]">
{task.category && (
<span className="flex items-center gap-1">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: categoryColor }}
/>
{task.category}
</span>
)}
{/* Category badge */}
{task.category && (
<span
className="inline-block text-[11px] font-medium px-2 py-0.5 rounded-full mb-2"
style={{
backgroundColor: categoryColor + '20',
color: categoryColor,
}}
>
{task.category}
</span>
)}
{/* Progress bar */}
{progressPercent !== null && (
<div className="h-1 w-full bg-white/5 rounded-full mb-2 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* Footer row */}
<div className="flex items-center justify-between text-[11px] text-[#64748b]">
<div className="flex items-center gap-2">
{elapsed && (
<span className="flex items-center gap-1">
<Clock size={12} />
{elapsed}
</span>
)}
</div>
{elapsed && (
<span className="flex items-center gap-1">
<Clock size={10} />
{elapsed}
</span>
)}
{totalSubTasks > 0 && (
<span className="flex items-center gap-1">
<CheckSquare size={12} />
{completedSubTasks}/{totalSubTasks}
<span className="ml-auto flex items-center gap-1">
{Array.from({ length: totalSubTasks }, (_, i) => (
<span
key={i}
className={`w-1 h-1 rounded-full ${
i < completedSubTasks
? 'bg-[var(--color-status-completed)]'
: 'bg-white/10'
}`}
/>
))}
<span className="ml-0.5">{completedSubTasks}/{totalSubTasks}</span>
</span>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion } from 'framer-motion'
import { X, Loader2 } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import {
@@ -40,9 +41,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
const completeTask = useCompleteTask()
const abandonTask = useAbandonTask()
// Slide-in animation state
const [visible, setVisible] = useState(false)
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
@@ -58,11 +56,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
const categoryInputRef = useRef<HTMLInputElement>(null)
const estimateInputRef = useRef<HTMLInputElement>(null)
// Trigger slide-in
useEffect(() => {
requestAnimationFrame(() => setVisible(true))
}, [])
// Escape key handler
const handleEscape = useCallback(
(e: KeyboardEvent) => {
@@ -75,10 +68,10 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
setEditingEstimate(false)
return
}
handleClose()
onClose()
}
},
[editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
)
useEffect(() => {
@@ -100,11 +93,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (editingEstimate) estimateInputRef.current?.focus()
}, [editingEstimate])
function handleClose() {
setVisible(false)
setTimeout(onClose, 200) // wait for slide-out animation
}
// --- Save handlers ---
function saveTitle() {
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
@@ -153,22 +141,25 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
return (
<>
{/* Overlay */}
<div
className={`fixed inset-0 z-40 transition-opacity duration-200 ${
visible ? 'bg-black/50' : 'bg-black/0'
}`}
onClick={handleClose}
<motion.div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed top-0 right-0 h-full w-[400px] z-50 bg-[#1a1d27] shadow-2xl flex flex-col transition-transform duration-200 ease-out ${
visible ? 'translate-x-0' : 'translate-x-full'
}`}
<motion.div
className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
{isLoading || !task ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-[#64748b]" size={32} />
<Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
</div>
) : (
<>
@@ -176,47 +167,47 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="p-5 pb-4">
{/* Close button */}
<div className="flex justify-end mb-3">
{/* Title row with close button inline */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<h2
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
</div>
<button
onClick={handleClose}
className="p-1 rounded hover:bg-white/10 text-[#64748b] hover:text-white transition-colors"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
>
<X size={18} />
</button>
</div>
{/* Title */}
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[#0f1117] text-lg font-semibold text-white px-3 py-2 rounded border border-indigo-500 outline-none"
/>
) : (
<h2
className="text-lg font-semibold text-white cursor-pointer hover:text-indigo-300 transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
{/* Status badge + Category */}
<div className="flex items-center gap-2 mt-3">
{statusConfig && (
<span
className="text-[11px] font-semibold uppercase px-2.5 py-1 rounded-full"
className="text-[10px] px-2.5 py-1 rounded-full"
style={{
backgroundColor: statusConfig.color + '20',
color: statusConfig.color,
@@ -238,11 +229,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (e.key === 'Escape') setEditingCategory(false)
}}
placeholder="Category..."
className="bg-[#0f1117] text-xs text-white px-2 py-1 rounded border border-indigo-500 outline-none w-28"
className="bg-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
/>
) : (
<span
className="text-[11px] text-[#64748b] cursor-pointer hover:text-white transition-colors px-2.5 py-1 rounded-full bg-white/5"
className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] transition-colors px-2.5 py-1 rounded-full bg-white/5"
onClick={() => {
setCategoryValue(task.category ?? '')
setEditingCategory(true)
@@ -254,11 +245,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
</div>
</div>
<div className="border-t border-[#2a2d37]" />
<div className="border-t border-[var(--color-border)]" />
{/* Description */}
<div className="p-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-2">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-2">
Description
</h3>
{editingDesc ? (
@@ -274,13 +265,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
}
}}
rows={4}
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-indigo-500 outline-none resize-none"
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none resize-none"
placeholder="Add a description..."
/>
) : (
<p
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
task.description ? 'text-[#c4c9d4]' : 'text-[#64748b] italic'
task.description ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
}`}
onClick={() => {
setDescValue(task.description ?? '')
@@ -292,23 +283,23 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)}
</div>
<div className="border-t border-[#2a2d37]" />
<div className="border-t border-[var(--color-border)]" />
{/* Time */}
<div className="p-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-3">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
Time
</h3>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<span className="text-[11px] text-[#64748b] block mb-1">Elapsed</span>
<span className="text-sm text-white font-medium">
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium">
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
</span>
</div>
<div>
<span className="text-[11px] text-[#64748b] block mb-1">Estimate</span>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Estimate</span>
{editingEstimate ? (
<input
ref={estimateInputRef}
@@ -321,11 +312,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (e.key === 'Escape') setEditingEstimate(false)
}}
placeholder="min"
className="w-full bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-indigo-500 outline-none"
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<span
className="text-sm text-white font-medium cursor-pointer hover:text-indigo-300 transition-colors"
className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
setEditingEstimate(true)
@@ -342,7 +333,9 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
progressPercent >= 100 ? 'bg-rose-500' : 'bg-indigo-500'
progressPercent >= 100
? 'bg-rose-500'
: 'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
}`}
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
@@ -350,14 +343,14 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)}
</div>
<div className="border-t border-[#2a2d37]" />
<div className="border-t border-[var(--color-border)]" />
{/* Subtasks */}
<div className="p-5">
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
</div>
<div className="border-t border-[#2a2d37]" />
<div className="border-t border-[var(--color-border)]" />
{/* Notes */}
<div className="p-5">
@@ -367,13 +360,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
{/* Action buttons - fixed at bottom */}
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
<div className="border-t border-[#2a2d37] p-5 space-y-2">
<div className="border-t border-[var(--color-border)] p-5 space-y-2">
{task.status === WorkTaskStatus.Pending && (
<>
<button
onClick={() => startTask.mutate(taskId)}
disabled={startTask.isPending}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Start
</button>
@@ -418,7 +411,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<button
onClick={() => resumeTask.mutate({ id: taskId })}
disabled={resumeTask.isPending}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Resume
</button>
@@ -442,7 +435,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)}
</>
)}
</div>
</motion.div>
</>
)
}

View File

@@ -74,7 +74,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
Loading activity...
</div>
)
@@ -82,7 +82,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
if (sortedEvents.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
No activity events for this time range.
</div>
)
@@ -90,30 +90,36 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
return (
<div>
<div className="divide-y divide-white/5">
{visibleEvents.map((evt) => {
<div className="relative">
{visibleEvents.map((evt, idx) => {
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
const detail = evt.url || evt.windowTitle || ''
const isLast = idx === visibleEvents.length - 1
return (
<div key={evt.id} className="flex items-start gap-3 py-3">
{/* Category dot */}
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: color }}
/>
<div key={evt.id} className="flex items-start gap-3 relative">
{/* Timeline connector + dot */}
<div className="flex flex-col items-center shrink-0">
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="w-px flex-1 bg-[var(--color-border)]" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 pb-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[#94a3b8] shrink-0">
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
{formatTimestamp(evt.timestamp)}
</span>
<span className="text-sm text-white font-medium truncate">{evt.appName}</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
</div>
{detail && (
<p className="text-xs text-[#64748b] truncate mt-0.5">{detail}</p>
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
)}
</div>
</div>
@@ -124,7 +130,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
{hasMore && (
<button
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
className="mt-3 w-full py-2 text-sm text-[#94a3b8] hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
className="mt-3 w-full py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Load more ({sortedEvents.length - visibleCount} remaining)
</button>

View File

@@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading category breakdown...
</div>
)
@@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
if (categories.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No category data available.
</div>
)
@@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
return (
<div
style={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-white text-sm font-medium">{d.name}</div>
<div className="text-[#94a3b8] text-xs mt-0.5">
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
{d.count} events ({d.percentage}%)
</div>
</div>
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
{/* Name + bar + stats */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
{cat.count} ({cat.percentage}%)
</span>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{

View File

@@ -136,7 +136,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading timeline...
</div>
)
@@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
if (buckets.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No activity data for this time range.
</div>
)
@@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<XAxis
dataKey="label"
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#1e293b' }}
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={{ stroke: 'var(--color-border)' }}
tickLine={false}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 12 }}
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
@@ -169,8 +169,8 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
@@ -181,14 +181,14 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
return (
<div
style={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
<div className="text-white text-sm font-medium mt-0.5">{d.appName}</div>
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
<div className="text-xs mt-0.5" style={{ color: d.color }}>
{d.count} events
</div>

View File

@@ -2,22 +2,81 @@
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--color-page: #0a0a0f;
--color-surface: #12131a;
--color-elevated: #1a1b26;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-hover: rgba(255, 255, 255, 0.12);
--color-text-primary: #e2e8f0;
--color-text-secondary: #64748b;
--color-text-tertiary: #334155;
--color-accent: #8b5cf6;
--color-accent-end: #6366f1;
--color-status-active: #3b82f6;
--color-status-paused: #eab308;
--color-status-completed: #22c55e;
--color-status-pending: #64748b;
}
/* Noise grain texture */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* Active task pulse */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 8px rgba(6, 182, 212, 0.3);
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 20px rgba(6, 182, 212, 0.5);
box-shadow: 0 0 16px rgba(59, 130, 246, 0.5);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
animation: pulse-glow 2.5s ease-in-out infinite;
}
/* Custom scrollbar for dark theme */
/* Live dot pulse */
@keyframes live-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.animate-live-dot {
animation: live-dot 1.5s ease-in-out infinite;
}
/* Card hover glow border */
.card-glow {
position: relative;
}
.card-glow::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.1), transparent);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
pointer-events: none;
}
.card-glow:hover::before {
opacity: 1;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
@@ -27,10 +86,15 @@
}
::-webkit-scrollbar-thumb {
background: #2a2d37;
background: #1a1b26;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3d47;
background: #2a2d37;
}
/* Selection color */
::selection {
background: rgba(139, 92, 246, 0.3);
}

View File

@@ -1,8 +1,8 @@
export const COLUMN_CONFIG = [
{ status: 'Pending' as const, label: 'Pending', color: '#94a3b8' },
{ status: 'Active' as const, label: 'Active', color: '#06b6d4' },
{ status: 'Paused' as const, label: 'Paused', color: '#f59e0b' },
{ status: 'Completed' as const, label: 'Completed', color: '#10b981' },
{ status: 'Pending' as const, label: 'Pending', color: '#64748b' },
{ status: 'Active' as const, label: 'Active', color: '#3b82f6' },
{ status: 'Paused' as const, label: 'Paused', color: '#eab308' },
{ status: 'Completed' as const, label: 'Completed', color: '#22c55e' },
] as const
export const CATEGORY_COLORS: Record<string, string> = {
@@ -12,5 +12,10 @@ export const CATEGORY_COLORS: Record<string, string> = {
DevOps: '#f97316',
Documentation: '#14b8a6',
Design: '#ec4899',
Unknown: '#64748b',
Testing: '#3b82f6',
General: '#64748b',
Email: '#f59e0b',
Engineering: '#6366f1',
LaserCutting: '#ef4444',
Unknown: '#475569',
}

View File

@@ -19,14 +19,14 @@ export default function Analytics() {
<div className="max-w-6xl mx-auto space-y-8">
{/* Header + Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl font-semibold text-white">Analytics</h1>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
<div className="flex items-center gap-3">
{/* Time range dropdown */}
<select
value={minutes}
onChange={(e) => setMinutes(Number(e.target.value))}
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer"
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
>
{TIME_RANGES.map((r) => (
<option key={r.minutes} value={r.minutes}>
@@ -39,7 +39,7 @@ export default function Analytics() {
<select
value={taskId ?? ''}
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer max-w-[200px]"
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
>
<option value="">All Tasks</option>
{tasks?.map((t) => (
@@ -51,32 +51,69 @@ export default function Analytics() {
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const totalMins = tasks?.reduce((acc, t) => {
if (!t.startedAt) return acc
const start = new Date(t.startedAt).getTime()
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
return acc + (end - start) / 60000
}, 0) ?? 0
const hours = Math.floor(totalMins / 60)
const mins = Math.floor(totalMins % 60)
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
})()}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const counts: Record<string, number> = {}
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
return top ? top[0] : '\u2014'
})()}
</p>
</div>
</div>
{/* Timeline */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Activity Timeline
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<Timeline minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Category Breakdown */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Category Breakdown
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<CategoryBreakdown minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Recent Activity
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<ActivityFeed minutes={minutes} taskId={taskId} />
</div>
</section>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Pencil, Trash2, Check, X, Plus } from 'lucide-react'
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
import { CATEGORY_COLORS } from '../lib/constants'
import type { AppMapping } from '../types'
@@ -102,13 +102,13 @@ export default function Mappings() {
}
const inputClass =
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors w-full'
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
const selectClass =
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer w-full'
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
return (
<tr className="bg-[#1a1d27]">
<tr className="bg-white/[0.04]">
<td className="px-4 py-3">
<input
type="text"
@@ -162,7 +162,7 @@ export default function Mappings() {
</button>
<button
onClick={onCancel}
className="p-1.5 rounded text-[#94a3b8] hover:bg-white/5 transition-colors"
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
title="Cancel"
>
<X size={16} />
@@ -177,7 +177,7 @@ export default function Mappings() {
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">App Mappings</h1>
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
<button
onClick={() => {
setAddingNew(true)
@@ -185,7 +185,7 @@ export default function Mappings() {
setNewForm(emptyForm)
}}
disabled={addingNew}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
>
<Plus size={16} />
Add Rule
@@ -194,33 +194,34 @@ export default function Mappings() {
{/* Table */}
{isLoading ? (
<div className="text-[#94a3b8] text-sm py-12 text-center">Loading mappings...</div>
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
) : !mappings?.length && !addingNew ? (
<div className="bg-[#161922] rounded-xl border border-white/5 p-12 text-center">
<p className="text-[#94a3b8] text-sm mb-3">No mappings configured</p>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
<button
onClick={() => {
setAddingNew(true)
setNewForm(emptyForm)
}}
className="text-indigo-400 hover:text-indigo-300 text-sm font-medium transition-colors"
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
>
+ Add your first mapping rule
</button>
</div>
) : (
<div className="bg-[#161922] rounded-xl border border-white/5 overflow-hidden">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#161922] border-b border-white/5">
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Pattern</th>
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Match Type</th>
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Category</th>
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Friendly Name</th>
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium w-24">Actions</th>
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
<tbody>
{/* Add-new row */}
{addingNew &&
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
@@ -232,9 +233,9 @@ export default function Mappings() {
) : (
<tr
key={m.id}
className="bg-[#1a1d27] hover:bg-[#1e2230] transition-colors"
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 text-white font-mono text-xs">{m.pattern}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
<td className="px-4 py-3">
<span
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
@@ -247,7 +248,7 @@ export default function Mappings() {
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-white text-xs">
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
@@ -255,21 +256,21 @@ export default function Mappings() {
{m.category}
</span>
</td>
<td className="px-4 py-3 text-[#94a3b8]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{m.friendlyName ?? '\u2014'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => handleEditStart(m)}
className="p-1.5 rounded text-[#94a3b8] hover:text-white hover:bg-white/5 transition-colors"
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1.5 rounded text-[#94a3b8] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Delete"
>
<Trash2 size={14} />

View File

@@ -13,4 +13,22 @@ internal static partial class NativeMethods
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[StructLayout(LayoutKind.Sequential)]
internal struct LASTINPUTINFO
{
public uint cbSize;
public uint dwTime;
}
[DllImport("user32.dll")]
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
internal static TimeSpan GetIdleTime()
{
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
if (!GetLastInputInfo(ref info))
return TimeSpan.Zero;
return TimeSpan.FromMilliseconds(Environment.TickCount64 - info.dwTime);
}
}

View File

@@ -7,4 +7,5 @@ public class WindowWatcherOptions
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
public int PollIntervalMs { get; set; } = 2000;
public int DebounceMs { get; set; } = 3000;
public int IdleTimeoutMs { get; set; } = 300_000;
}

View File

@@ -15,12 +15,15 @@ public class Worker(
private string _lastAppName = string.Empty;
private string _lastWindowTitle = string.Empty;
private DateTime _lastChangeTime = DateTime.MinValue;
private bool _isIdle;
private int? _pausedTaskId;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = options.Value;
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
config.PollIntervalMs, config.DebounceMs);
logger.LogInformation(
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
while (!stoppingToken.IsCancellationRequested)
{
@@ -67,6 +70,21 @@ public class Worker(
_lastWindowTitle = windowTitle;
_lastChangeTime = now;
}
// Idle detection
var idleTime = NativeMethods.GetIdleTime();
if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
{
_isIdle = true;
logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
await PauseActiveTaskAsync(ct: stoppingToken);
}
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
{
_isIdle = false;
logger.LogInformation("User returned from idle");
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -108,4 +126,80 @@ public class Worker(
logger.LogWarning(ex, "Failed to report context event to API");
}
}
private async Task PauseActiveTaskAsync(CancellationToken ct)
{
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
// Get the active task
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
"/api/tasks/active", ct);
if (response?.Data is null)
{
logger.LogDebug("No active task to pause");
return;
}
_pausedTaskId = response.Data.Id;
// Pause it
var pauseResponse = await client.PutAsJsonAsync(
$"/api/tasks/{_pausedTaskId}/pause",
new { note = "Auto-paused: idle timeout" }, ct);
if (pauseResponse.IsSuccessStatusCode)
logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
else
logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to pause active task on idle");
}
}
private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
{
if (_pausedTaskId is null)
return;
var taskId = _pausedTaskId.Value;
_pausedTaskId = null;
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
// Check the task is still paused (user may have manually switched tasks)
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
$"/api/tasks/{taskId}", ct);
if (response?.Data is null || response.Data.Status != "Paused")
{
logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
return;
}
// Resume it
var resumeResponse = await client.PutAsJsonAsync(
$"/api/tasks/{taskId}/resume",
new { note = "Auto-resumed: user returned" }, ct);
if (resumeResponse.IsSuccessStatusCode)
logger.LogInformation("Auto-resumed task {TaskId}", taskId);
else
logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId);
}
}
}
internal record ActiveTaskDto(int Id, string Status);
internal record ApiResponse<T>(bool Success, T? Data, string? Error);

View File

@@ -8,6 +8,7 @@
"WindowWatcher": {
"ApiBaseUrl": "http://localhost:5200",
"PollIntervalMs": 2000,
"DebounceMs": 3000
"DebounceMs": 3000,
"IdleTimeoutMs": 300000
}
}

View 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

View 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"
```