From 4c160fe434aca1d7339112068916a71e2a8a2f04 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:00:10 -0500 Subject: [PATCH 01/14] =?UTF-8?q?feat(ui):=20add=20design=20foundation=20?= =?UTF-8?q?=E2=80=94=20CSS=20tokens,=20noise=20texture,=20card=20glow=20ef?= =?UTF-8?q?fects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/index.html | 2 +- TaskTracker.Web/package-lock.json | 45 +++++++++++++++- TaskTracker.Web/package.json | 1 + TaskTracker.Web/src/index.css | 76 +++++++++++++++++++++++++--- TaskTracker.Web/src/lib/constants.ts | 15 ++++-- 5 files changed, 125 insertions(+), 14 deletions(-) diff --git a/TaskTracker.Web/index.html b/TaskTracker.Web/index.html index 22fe403..f27f58a 100644 --- a/TaskTracker.Web/index.html +++ b/TaskTracker.Web/index.html @@ -7,7 +7,7 @@ - tasktracker-web + TaskTracker
diff --git a/TaskTracker.Web/package-lock.json b/TaskTracker.Web/package-lock.json index 6aec30f..9b4d73b 100644 --- a/TaskTracker.Web/package-lock.json +++ b/TaskTracker.Web/package-lock.json @@ -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", diff --git a/TaskTracker.Web/package.json b/TaskTracker.Web/package.json index 3c4bab5..d47d204 100644 --- a/TaskTracker.Web/package.json +++ b/TaskTracker.Web/package.json @@ -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", diff --git a/TaskTracker.Web/src/index.css b/TaskTracker.Web/src/index.css index 9540225..a9661ed 100644 --- a/TaskTracker.Web/src/index.css +++ b/TaskTracker.Web/src/index.css @@ -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); } diff --git a/TaskTracker.Web/src/lib/constants.ts b/TaskTracker.Web/src/lib/constants.ts index 291eb95..f2f1e57 100644 --- a/TaskTracker.Web/src/lib/constants.ts +++ b/TaskTracker.Web/src/lib/constants.ts @@ -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 = { @@ -12,5 +12,10 @@ export const CATEGORY_COLORS: Record = { DevOps: '#f97316', Documentation: '#14b8a6', Design: '#ec4899', - Unknown: '#64748b', + Testing: '#3b82f6', + General: '#64748b', + Email: '#f59e0b', + Engineering: '#6366f1', + LaserCutting: '#ef4444', + Unknown: '#475569', } From 8f2eae425cb47cbfb07a84665c4e70b3a211e0ad Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:02:43 -0500 Subject: [PATCH 02/14] feat(ui): replace sidebar with slim top navigation bar Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/components/Layout.tsx | 111 +++++++++++++++------- 1 file changed, 75 insertions(+), 36 deletions(-) diff --git a/TaskTracker.Web/src/components/Layout.tsx b/TaskTracker.Web/src/components/Layout.tsx index 0aef1e5..7b9a3f5 100644 --- a/TaskTracker.Web/src/components/Layout.tsx +++ b/TaskTracker.Web/src/components/Layout.tsx @@ -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 ( -
- {/* Sidebar */} - - {/* Main area */} -
- {/* Top bar */} -
-

TaskTracker

- navigate(`/board?task=${taskId}`)} /> -
+ {/* New task button */} + + - {/* Content */} -
- -
-
+ {/* Content */} +
+ +
+ + {/* Search modal */} + {searchOpen && ( + { + setSearchOpen(false) + navigate(`/board?task=${taskId}`) + }} + onClose={() => setSearchOpen(false)} + /> + )}
) } From 320ef51c749ff2a9ea671bd165a163f4fc0db5d6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:02:48 -0500 Subject: [PATCH 03/14] feat(ui): add Command-K search modal, remove inline search bar Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/components/SearchBar.tsx | 179 ---------------- .../src/components/SearchModal.tsx | 192 ++++++++++++++++++ 2 files changed, 192 insertions(+), 179 deletions(-) delete mode 100644 TaskTracker.Web/src/components/SearchBar.tsx create mode 100644 TaskTracker.Web/src/components/SearchModal.tsx diff --git a/TaskTracker.Web/src/components/SearchBar.tsx b/TaskTracker.Web/src/components/SearchBar.tsx deleted file mode 100644 index f167f40..0000000 --- a/TaskTracker.Web/src/components/SearchBar.tsx +++ /dev/null @@ -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(null) - const inputRef = useRef(null) - const timerRef = useRef | 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 ( -
-
- - 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" - /> -
- - {isOpen && results.length > 0 && ( -
- {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 ( - - ) - })} -
- )} -
- ) -} diff --git a/TaskTracker.Web/src/components/SearchModal.tsx b/TaskTracker.Web/src/components/SearchModal.tsx new file mode 100644 index 0000000..06b0295 --- /dev/null +++ b/TaskTracker.Web/src/components/SearchModal.tsx @@ -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(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 ( + + + {/* Backdrop */} +
+ + {/* Modal */} + + {/* Search input */} +
+ + 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" + /> + + ESC + +
+ + {/* Results */} + {results.length > 0 ? ( +
+ {!query.trim() && ( +
+ Recent tasks +
+ )} + {results.map((task, index) => { + const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown'] + const statusColor = getStatusColor(task.status) + + return ( + + ) + })} +
+ ) : query.trim() ? ( +
+ No tasks found +
+ ) : null} + + {/* Footer */} +
+ + ↑↓ + Navigate + + + + Open + +
+
+ + + ) +} From 5ec4ca9a6257fd6f6be7171b8d18f6fe486685fa Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:05:05 -0500 Subject: [PATCH 04/14] =?UTF-8?q?feat(ui):=20redesign=20kanban=20columns?= =?UTF-8?q?=20and=20task=20cards=20=E2=80=94=20borderless=20columns,=20glo?= =?UTF-8?q?w=20cards,=20live=20dots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../src/components/KanbanBoard.tsx | 2 +- .../src/components/KanbanColumn.tsx | 52 ++++----- TaskTracker.Web/src/components/TaskCard.tsx | 109 ++++++++---------- 3 files changed, 76 insertions(+), 87 deletions(-) diff --git a/TaskTracker.Web/src/components/KanbanBoard.tsx b/TaskTracker.Web/src/components/KanbanBoard.tsx index add99ea..7cd6494 100644 --- a/TaskTracker.Web/src/components/KanbanBoard.tsx +++ b/TaskTracker.Web/src/components/KanbanBoard.tsx @@ -143,7 +143,7 @@ export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoa {activeTask ? ( -
+
{}} />
) : null} diff --git a/TaskTracker.Web/src/components/KanbanColumn.tsx b/TaskTracker.Web/src/components/KanbanColumn.tsx index fd0365d..b40d4ce 100644 --- a/TaskTracker.Web/src/components/KanbanColumn.tsx +++ b/TaskTracker.Web/src/components/KanbanColumn.tsx @@ -28,56 +28,56 @@ export default function KanbanColumn({ const taskIds = tasks.map((t) => t.id) return ( -
+
{/* Column header */} -
-
-

{label}

- +
+
+

+ {label} +

+ {tasks.length}
-
+
{/* Cards area */}
{tasks.map((task) => ( ))} + + {/* Empty state */} + {tasks.length === 0 && !showForm && ( +
+ No tasks +
+ )}
- {/* Add task form / button (Pending column only) */} + {/* Add task (Pending column only) */} {status === WorkTaskStatus.Pending && ( -
+
{showForm ? ( setShowForm(false)} /> ) : ( )}
diff --git a/TaskTracker.Web/src/components/TaskCard.tsx b/TaskTracker.Web/src/components/TaskCard.tsx index dcb414b..a40ed7b 100644 --- a/TaskTracker.Web/src/components/TaskCard.tsx +++ b/TaskTracker.Web/src/components/TaskCard.tsx @@ -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 (
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 */} -
+
+ {/* Title row */} +
+ {isActive && ( + + )} +

+ {task.title} +

+
-
- {/* Title */} -

- {task.title} -

+ {/* Meta row */} +
+ {task.category && ( + + + {task.category} + + )} - {/* Category badge */} - {task.category && ( - - {task.category} - - )} - - {/* Progress bar */} - {progressPercent !== null && ( -
-
-
- )} - - {/* Footer row */} -
-
- {elapsed && ( - - - {elapsed} - - )} -
+ {elapsed && ( + + + {elapsed} + + )} {totalSubTasks > 0 && ( - - - {completedSubTasks}/{totalSubTasks} + + {Array.from({ length: totalSubTasks }, (_, i) => ( + + ))} + {completedSubTasks}/{totalSubTasks} )}
From 420ba50517e1f284c5aa788a7a6352aaee7d8f85 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:07:28 -0500 Subject: [PATCH 05/14] =?UTF-8?q?feat(ui):=20redesign=20detail=20panel=20?= =?UTF-8?q?=E2=80=94=20frosted=20glass,=20spring=20animation,=20refined=20?= =?UTF-8?q?hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../src/components/TaskDetailPanel.tsx | 145 +++++++++--------- 1 file changed, 69 insertions(+), 76 deletions(-) diff --git a/TaskTracker.Web/src/components/TaskDetailPanel.tsx b/TaskTracker.Web/src/components/TaskDetailPanel.tsx index 5798255..ee769c3 100644 --- a/TaskTracker.Web/src/components/TaskDetailPanel.tsx +++ b/TaskTracker.Web/src/components/TaskDetailPanel.tsx @@ -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(null) const estimateInputRef = useRef(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 */} -
{/* Panel */} -
{isLoading || !task ? (
- +
) : ( <> @@ -176,47 +167,47 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
{/* Header */}
- {/* Close button */} -
+ {/* Title row with close button inline */} +
+
+ {editingTitle ? ( + 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" + /> + ) : ( +

{ + setTitleValue(task.title) + setEditingTitle(true) + }} + > + {task.title} +

+ )} +
- {/* Title */} - {editingTitle ? ( - 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" - /> - ) : ( -

{ - setTitleValue(task.title) - setEditingTitle(true) - }} - > - {task.title} -

- )} - {/* Status badge + Category */}
{statusConfig && ( ) : ( { setCategoryValue(task.category ?? '') setEditingCategory(true) @@ -254,11 +245,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
-
+
{/* Description */}
-

+

Description

{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..." /> ) : (

{ setDescValue(task.description ?? '') @@ -292,23 +283,23 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp )}

-
+
{/* Time */}
-

+

Time

- Elapsed - + Elapsed + {task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
- Estimate + Estimate {editingEstimate ? ( ) : ( { setEstimateValue(task.estimatedMinutes?.toString() ?? '') setEditingEstimate(true) @@ -342,7 +333,9 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
= 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 )}
-
+
{/* Subtasks */}
-
+
{/* Notes */}
@@ -367,13 +360,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp {/* Action buttons - fixed at bottom */} {task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && ( -
+
{task.status === WorkTaskStatus.Pending && ( <> @@ -418,7 +411,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp @@ -442,7 +435,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp )} )} -
+ ) } From 8c2377f9564638410a06fb61c38bc082fd489ebd Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:07:45 -0500 Subject: [PATCH 06/14] docs: add idle detection design for WindowWatcher Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-02-27-idle-detection-design.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/plans/2026-02-27-idle-detection-design.md diff --git a/docs/plans/2026-02-27-idle-detection-design.md b/docs/plans/2026-02-27-idle-detection-design.md new file mode 100644 index 0000000..6583e91 --- /dev/null +++ b/docs/plans/2026-02-27-idle-detection-design.md @@ -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 From 664fd720861ccbe680532d178e81b3ebcfe1655c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:08:55 -0500 Subject: [PATCH 07/14] docs: add idle detection implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-27-idle-detection-plan.md | 263 +++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 docs/plans/2026-02-27-idle-detection-plan.md diff --git a/docs/plans/2026-02-27-idle-detection-plan.md b/docs/plans/2026-02-27-idle-detection-plan.md new file mode 100644 index 0000000..d04951f --- /dev/null +++ b/docs/plans/2026-02-27-idle-detection-plan.md @@ -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() }; + 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>( + "/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>( + $"/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` 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" +``` From 0ea3fcfa6def194234928e4131e7e44b414b95fb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:09:51 -0500 Subject: [PATCH 08/14] feat(ui): restyle filter bar and create task form with design tokens Co-Authored-By: Claude Opus 4.6 --- .../src/components/CreateTaskForm.tsx | 8 ++++---- TaskTracker.Web/src/components/FilterBar.tsx | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/TaskTracker.Web/src/components/CreateTaskForm.tsx b/TaskTracker.Web/src/components/CreateTaskForm.tsx index 4c63a16..bc74844 100644 --- a/TaskTracker.Web/src/components/CreateTaskForm.tsx +++ b/TaskTracker.Web/src/components/CreateTaskForm.tsx @@ -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 (
{/* Title */} @@ -104,7 +104,7 @@ export default function CreateTaskForm({ onClose }: CreateTaskFormProps) { @@ -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" > diff --git a/TaskTracker.Web/src/components/FilterBar.tsx b/TaskTracker.Web/src/components/FilterBar.tsx index c1d6127..ee2421f 100644 --- a/TaskTracker.Web/src/components/FilterBar.tsx +++ b/TaskTracker.Web/src/components/FilterBar.tsx @@ -62,17 +62,17 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar {/* "All" chip */} {/* Divider */} -
+
{/* Category chips */} {allCategories.map((cat) => { @@ -83,8 +83,8 @@ export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBar diff --git a/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx b/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx index 50fcf52..9c40f22 100644 --- a/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx +++ b/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx @@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId } if (isLoading) { return ( -
+
Loading category breakdown...
) @@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId } if (categories.length === 0) { return ( -
+
No category data available.
) @@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId } return (
-
{d.name}
-
+
{d.name}
+
{d.count} events ({d.percentage}%)
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId } {/* Name + bar + stats */}
- {cat.name} - + {cat.name} + {cat.count} ({cat.percentage}%)
{/* Progress bar */} -
+
+
Loading timeline...
) @@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) { if (buckets.length === 0) { return ( -
+
No activity data for this time range.
) @@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) { -
{d.timeRange}
-
{d.appName}
+
{d.timeRange}
+
{d.appName}
{d.count} events
diff --git a/TaskTracker.Web/src/pages/Analytics.tsx b/TaskTracker.Web/src/pages/Analytics.tsx index 204f80d..2458dc8 100644 --- a/TaskTracker.Web/src/pages/Analytics.tsx +++ b/TaskTracker.Web/src/pages/Analytics.tsx @@ -19,14 +19,14 @@ export default function Analytics() {
{/* Header + Filters */}
-

Analytics

+

Analytics

{/* Time range dropdown */} 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]" > {tasks?.map((t) => ( @@ -51,32 +51,69 @@ export default function Analytics() {
+ {/* Stat cards */} +
+
+ Open Tasks +

+ {tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0} +

+
+
+ Active Time +

+ {(() => { + 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` + })()} +

+
+
+ Top Category +

+ {(() => { + const counts: Record = {} + 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' + })()} +

+
+
+ {/* Timeline */}
-

+

Activity Timeline

-
+
{/* Category Breakdown */}
-

+

Category Breakdown

-
+
{/* Activity Feed */}
-

+

Recent Activity

-
+
From 400e74be510585ef32d6433244b24cd08f3cd034 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:11:40 -0500 Subject: [PATCH 10/14] feat(ui): redesign mappings page with design tokens and refined table Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/pages/Mappings.tsx | 51 +++++++++++++------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/TaskTracker.Web/src/pages/Mappings.tsx b/TaskTracker.Web/src/pages/Mappings.tsx index 5020f01..687b7f1 100644 --- a/TaskTracker.Web/src/pages/Mappings.tsx +++ b/TaskTracker.Web/src/pages/Mappings.tsx @@ -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 ( - +
) : ( -
+
- - - - - - + + + + + + - + {/* Add-new row */} {addingNew && renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)} @@ -232,9 +233,9 @@ export default function Mappings() { ) : ( - + -
PatternMatch TypeCategoryFriendly NameActions
PatternMatch TypeCategoryFriendly NameActions
{m.pattern}{m.pattern} - + + {m.friendlyName ?? '\u2014'}
@@ -86,25 +86,21 @@ export default function NotesList({ taskId, notes }: NotesListProps) {
{typeConfig.label} - + {formatRelativeTime(note.createdAt)}
-

{note.content}

+

{note.content}

) })} {sortedNotes.length === 0 && !showInput && ( -

No notes yet

+

No notes yet

)} {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)]" /> )}
diff --git a/TaskTracker.Web/src/components/SubtaskList.tsx b/TaskTracker.Web/src/components/SubtaskList.tsx index 0b1bfc9..21b5e46 100644 --- a/TaskTracker.Web/src/components/SubtaskList.tsx +++ b/TaskTracker.Web/src/components/SubtaskList.tsx @@ -46,12 +46,12 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) { return (
-

+

Subtasks

@@ -67,13 +67,13 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) { onClick={() => handleToggle(subtask)} > {isCompleted ? ( - + ) : ( - + )} {subtask.title} @@ -84,7 +84,7 @@ export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) { {showInput && (
- +
)} From c71795c13ffeb82dae947f7b5407e4c11d68cb99 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:12:29 -0500 Subject: [PATCH 13/14] feat(watcher): add configurable IdleTimeoutMs setting (default 5 min) Co-Authored-By: Claude Opus 4.6 --- WindowWatcher/WindowWatcherOptions.cs | 1 + WindowWatcher/appsettings.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/WindowWatcher/WindowWatcherOptions.cs b/WindowWatcher/WindowWatcherOptions.cs index a0fa8e3..4ed59f5 100644 --- a/WindowWatcher/WindowWatcherOptions.cs +++ b/WindowWatcher/WindowWatcherOptions.cs @@ -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; } diff --git a/WindowWatcher/appsettings.json b/WindowWatcher/appsettings.json index caa3a46..e8bfbca 100644 --- a/WindowWatcher/appsettings.json +++ b/WindowWatcher/appsettings.json @@ -8,6 +8,7 @@ "WindowWatcher": { "ApiBaseUrl": "http://localhost:5200", "PollIntervalMs": 2000, - "DebounceMs": 3000 + "DebounceMs": 3000, + "IdleTimeoutMs": 300000 } } From 71d33e355c652ce6fb5d1f5598ba9c12e78e407b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:14:36 -0500 Subject: [PATCH 14/14] feat(watcher): add idle detection with auto-pause/resume When user is idle beyond IdleTimeoutMs, automatically pauses the active task via the API. When user returns, resumes the task if it's still paused. Co-Authored-By: Claude Opus 4.6 --- WindowWatcher/Worker.cs | 98 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/WindowWatcher/Worker.cs b/WindowWatcher/Worker.cs index a46ea81..b8d99df 100644 --- a/WindowWatcher/Worker.cs +++ b/WindowWatcher/Worker.cs @@ -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>( + "/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>( + $"/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(bool Success, T? Data, string? Error);