From af205b367d205224316f539100f17bcf8b94f5ec Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 26 Feb 2026 22:22:57 -0500 Subject: [PATCH] feat: add task detail slide-over panel with inline editing, subtasks, and notes Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/api/notes.ts | 12 + TaskTracker.Web/src/components/NotesList.tsx | 130 +++++ .../src/components/SubtaskList.tsx | 108 +++++ .../src/components/TaskDetailPanel.tsx | 448 ++++++++++++++++++ TaskTracker.Web/src/pages/Board.tsx | 8 +- 5 files changed, 702 insertions(+), 4 deletions(-) create mode 100644 TaskTracker.Web/src/api/notes.ts create mode 100644 TaskTracker.Web/src/components/NotesList.tsx create mode 100644 TaskTracker.Web/src/components/SubtaskList.tsx create mode 100644 TaskTracker.Web/src/components/TaskDetailPanel.tsx diff --git a/TaskTracker.Web/src/api/notes.ts b/TaskTracker.Web/src/api/notes.ts new file mode 100644 index 0000000..1bc388e --- /dev/null +++ b/TaskTracker.Web/src/api/notes.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { request } from './client' +import type { TaskNote } from '../types' + +export function useCreateNote() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ taskId, content, type }: { taskId: number; content: string; type: number }) => + request({ method: 'POST', url: `/tasks/${taskId}/notes`, data: { content, type } }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + }) +} diff --git a/TaskTracker.Web/src/components/NotesList.tsx b/TaskTracker.Web/src/components/NotesList.tsx new file mode 100644 index 0000000..90fdd34 --- /dev/null +++ b/TaskTracker.Web/src/components/NotesList.tsx @@ -0,0 +1,130 @@ +import { useState, useRef, useEffect } from 'react' +import { Plus } from 'lucide-react' +import { NoteType } from '../types/index.ts' +import type { TaskNote } from '../types/index.ts' +import { useCreateNote } from '../api/notes.ts' + +interface NotesListProps { + taskId: number + notes: TaskNote[] +} + +const NOTE_TYPE_CONFIG: Record = { + [NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' }, + [NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' }, + [NoteType.General]: { label: 'General', color: '#64748b' }, +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr) + const now = Date.now() + const diffMs = now - date.getTime() + const diffMins = Math.floor(diffMs / 60_000) + + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + if (diffDays === 1) return 'yesterday' + if (diffDays < 7) return `${diffDays}d ago` + const diffWeeks = Math.floor(diffDays / 7) + if (diffWeeks < 5) return `${diffWeeks}w ago` + return date.toLocaleDateString() +} + +export default function NotesList({ taskId, notes }: NotesListProps) { + const [showInput, setShowInput] = useState(false) + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + const createNote = useCreateNote() + + useEffect(() => { + if (showInput) inputRef.current?.focus() + }, [showInput]) + + // Chronological order (oldest first) + const sortedNotes = [...notes].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ) + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && inputValue.trim()) { + createNote.mutate( + { taskId, content: inputValue.trim(), type: NoteType.General }, + { + onSuccess: () => { + setInputValue('') + }, + } + ) + } + if (e.key === 'Escape') { + setShowInput(false) + setInputValue('') + } + } + + return ( +
+
+

+ Notes +

+ +
+ +
+ {sortedNotes.map((note) => { + const typeConfig = NOTE_TYPE_CONFIG[note.type] ?? NOTE_TYPE_CONFIG[NoteType.General] + return ( +
+
+ + {typeConfig.label} + + + {formatRelativeTime(note.createdAt)} + +
+

{note.content}

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

No notes yet

+ )} + + {showInput && ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!inputValue.trim()) { + setShowInput(false) + setInputValue('') + } + }} + 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]" + /> + )} +
+
+ ) +} diff --git a/TaskTracker.Web/src/components/SubtaskList.tsx b/TaskTracker.Web/src/components/SubtaskList.tsx new file mode 100644 index 0000000..0b1bfc9 --- /dev/null +++ b/TaskTracker.Web/src/components/SubtaskList.tsx @@ -0,0 +1,108 @@ +import { useState, useRef, useEffect } from 'react' +import { Plus, Square, CheckSquare } from 'lucide-react' +import { WorkTaskStatus } from '../types/index.ts' +import type { WorkTask } from '../types/index.ts' +import { useCreateTask, useCompleteTask } from '../api/tasks.ts' + +interface SubtaskListProps { + taskId: number + subtasks: WorkTask[] +} + +export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) { + const [showInput, setShowInput] = useState(false) + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + const createTask = useCreateTask() + const completeTask = useCompleteTask() + + useEffect(() => { + if (showInput) inputRef.current?.focus() + }, [showInput]) + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && inputValue.trim()) { + createTask.mutate( + { title: inputValue.trim(), parentTaskId: taskId }, + { + onSuccess: () => { + setInputValue('') + }, + } + ) + } + if (e.key === 'Escape') { + setShowInput(false) + setInputValue('') + } + } + + function handleToggle(subtask: WorkTask) { + if (subtask.status !== WorkTaskStatus.Completed) { + completeTask.mutate(subtask.id) + } + } + + return ( +
+
+

+ Subtasks +

+ +
+ +
+ {subtasks.map((subtask) => { + const isCompleted = subtask.status === WorkTaskStatus.Completed + return ( +
handleToggle(subtask)} + > + {isCompleted ? ( + + ) : ( + + )} + + {subtask.title} + +
+ ) + })} + + {showInput && ( +
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!inputValue.trim()) { + setShowInput(false) + setInputValue('') + } + }} + 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]" + /> +
+ )} +
+
+ ) +} diff --git a/TaskTracker.Web/src/components/TaskDetailPanel.tsx b/TaskTracker.Web/src/components/TaskDetailPanel.tsx new file mode 100644 index 0000000..5798255 --- /dev/null +++ b/TaskTracker.Web/src/components/TaskDetailPanel.tsx @@ -0,0 +1,448 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { X, Loader2 } from 'lucide-react' +import { WorkTaskStatus } from '../types/index.ts' +import { + useTask, + useUpdateTask, + useStartTask, + usePauseTask, + useResumeTask, + useCompleteTask, + useAbandonTask, +} from '../api/tasks.ts' +import { COLUMN_CONFIG } from '../lib/constants.ts' +import SubtaskList from './SubtaskList.tsx' +import NotesList from './NotesList.tsx' + +interface TaskDetailPanelProps { + taskId: number + onClose: () => void +} + +function formatElapsed(startedAt: string, completedAt: string | null): string { + const start = new Date(startedAt).getTime() + const end = completedAt ? new Date(completedAt).getTime() : Date.now() + const mins = Math.floor((end - start) / 60_000) + if (mins < 60) return `${mins}m` + const hours = Math.floor(mins / 60) + const remainder = mins % 60 + if (hours < 24) return `${hours}h ${remainder}m` + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` +} + +export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) { + const { data: task, isLoading } = useTask(taskId) + const updateTask = useUpdateTask() + const startTask = useStartTask() + const pauseTask = usePauseTask() + const resumeTask = useResumeTask() + 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('') + const [editingDesc, setEditingDesc] = useState(false) + const [descValue, setDescValue] = useState('') + const [editingCategory, setEditingCategory] = useState(false) + const [categoryValue, setCategoryValue] = useState('') + const [editingEstimate, setEditingEstimate] = useState(false) + const [estimateValue, setEstimateValue] = useState('') + + const titleInputRef = useRef(null) + const descInputRef = useRef(null) + const categoryInputRef = useRef(null) + const estimateInputRef = useRef(null) + + // Trigger slide-in + useEffect(() => { + requestAnimationFrame(() => setVisible(true)) + }, []) + + // Escape key handler + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // If editing, cancel editing first + if (editingTitle || editingDesc || editingCategory || editingEstimate) { + setEditingTitle(false) + setEditingDesc(false) + setEditingCategory(false) + setEditingEstimate(false) + return + } + handleClose() + } + }, + [editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps + ) + + useEffect(() => { + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [handleEscape]) + + // Focus inputs when entering edit mode + useEffect(() => { + if (editingTitle) titleInputRef.current?.focus() + }, [editingTitle]) + useEffect(() => { + if (editingDesc) descInputRef.current?.focus() + }, [editingDesc]) + useEffect(() => { + if (editingCategory) categoryInputRef.current?.focus() + }, [editingCategory]) + useEffect(() => { + 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) { + updateTask.mutate({ id: taskId, title: titleValue.trim() }) + } + setEditingTitle(false) + } + + function saveDescription() { + if (task && descValue !== (task.description ?? '')) { + updateTask.mutate({ id: taskId, description: descValue }) + } + setEditingDesc(false) + } + + function saveCategory() { + if (task && categoryValue.trim() !== (task.category ?? '')) { + updateTask.mutate({ id: taskId, category: categoryValue.trim() || undefined }) + } + setEditingCategory(false) + } + + function saveEstimate() { + const val = estimateValue.trim() === '' ? undefined : parseInt(estimateValue, 10) + if (task) { + const newVal = val && !isNaN(val) ? val : undefined + if (newVal !== (task.estimatedMinutes ?? undefined)) { + updateTask.mutate({ id: taskId, estimatedMinutes: newVal }) + } + } + setEditingEstimate(false) + } + + // --- Status helpers --- + const statusConfig = COLUMN_CONFIG.find((c) => c.status === task?.status) + + // Progress percent + 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 ( + <> + {/* Overlay */} +
+ + {/* Panel */} +
+ {isLoading || !task ? ( +
+ +
+ ) : ( + <> + {/* Scrollable content */} +
+ {/* Header */} +
+ {/* Close button */} +
+ +
+ + {/* 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 && ( + + {statusConfig.label} + + )} + + {editingCategory ? ( + setCategoryValue(e.target.value)} + onBlur={saveCategory} + onKeyDown={(e) => { + if (e.key === 'Enter') saveCategory() + 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" + /> + ) : ( + { + setCategoryValue(task.category ?? '') + setEditingCategory(true) + }} + > + {task.category || 'Add category'} + + )} +
+
+ +
+ + {/* Description */} +
+

+ Description +

+ {editingDesc ? ( +