From db1a2156afc645f598fcdbcdffbccf019b5c7aba Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 9 Sep 2026 01:31:59 +0000 Subject: [PATCH] feat(poweruser): keyboard-first task lists + bulk ops + calendar nav - j/k keyboard navigation in task list view with visual selection ring - Enter to open task, e for inline title edit, x to toggle complete - Shift+J/K to reorder tasks in list view - Bulk selection via Shift+Click/Cmd+Click with checkbox column - BulkActionBar: complete, set priority, set state, add tag, delete - Calendar arrow key navigation, t=today, n=new event - kbd hint legend on first load --- .../src/components/shell/quick-capture.tsx | 3 + apps/web/src/components/shell/status-bar.tsx | 3 + .../src/components/tasks/bulk-action-bar.tsx | 152 ++++++++++ apps/web/src/routes/_app.tsx | 7 +- apps/web/src/routes/_app/calendar.tsx | 60 +++- apps/web/src/routes/_app/tasks.tsx | 275 +++++++++++++++++- 6 files changed, 490 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/shell/quick-capture.tsx create mode 100644 apps/web/src/components/shell/status-bar.tsx create mode 100644 apps/web/src/components/tasks/bulk-action-bar.tsx diff --git a/apps/web/src/components/shell/quick-capture.tsx b/apps/web/src/components/shell/quick-capture.tsx new file mode 100644 index 0000000..3b527e0 --- /dev/null +++ b/apps/web/src/components/shell/quick-capture.tsx @@ -0,0 +1,3 @@ +export function QuickCapture() { + return null; +} diff --git a/apps/web/src/components/shell/status-bar.tsx b/apps/web/src/components/shell/status-bar.tsx new file mode 100644 index 0000000..167203c --- /dev/null +++ b/apps/web/src/components/shell/status-bar.tsx @@ -0,0 +1,3 @@ +export function StatusBar() { + return null; +} diff --git a/apps/web/src/components/tasks/bulk-action-bar.tsx b/apps/web/src/components/tasks/bulk-action-bar.tsx new file mode 100644 index 0000000..4b047e8 --- /dev/null +++ b/apps/web/src/components/tasks/bulk-action-bar.tsx @@ -0,0 +1,152 @@ +import { useState, useRef } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Trash2, X } from "lucide-react"; +import type { Task, State, StateGroup } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface BulkActionBarProps { + selectedCount: number; + selectedIds: string[]; + projectStates: State[]; + onClearSelection: () => void; + onComplete: () => void; + onBatchUpdate: (updates: Partial) => void; + onBatchDelete: () => void; +} + +export function BulkActionBar({ + selectedCount, + selectedIds, + projectStates, + onClearSelection, + onComplete, + onBatchUpdate, + onBatchDelete, +}: BulkActionBarProps) { + const [priority, setPriority] = useState(""); + const [stateId, setStateId] = useState(""); + const [tagName, setTagName] = useState(""); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const tagInputRef = useRef(null); + + const handlePriorityChange = (value: string) => { + setPriority(value); + if (value) { + onBatchUpdate({ priority: value as Task["priority"] }); + } + }; + + const handleStateChange = (value: string) => { + setStateId(value); + if (value) { + onBatchUpdate({ stateId: value || null }); + } + }; + + const handleTagAdd = () => { + if (tagName.trim()) { + onBatchUpdate({ tags: [{ id: "", name: tagName.trim(), color: null }] }); + setTagName(""); + tagInputRef.current?.focus(); + } + }; + + const handleDeleteConfirm = () => { + onBatchDelete(); + setDeleteDialogOpen(false); + }; + + return ( +
+ {selectedCount} selected + + + + + + {projectStates.length > 0 && ( + + )} + +
+ setTagName(e.target.value)} + placeholder="Tag" + className="w-24 h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTagAdd(); + } + }} + /> + +
+ + + + + + + + Delete {selectedCount} tasks + + Are you sure you want to delete {selectedCount} tasks? This action cannot be undone. + + + + Cancel + + Delete + + + + + + +
+ ); +} diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 32b5b3b..f1e67ae 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar"; import { Topbar } from "@/components/shell/topbar"; import { CommandPalette } from "@/components/shell/command-palette"; import { ShortcutsHelp } from "@/components/shell/shortcuts-help"; +import { QuickCapture } from "@/components/shell/quick-capture"; +import { StatusBar } from "@/components/shell/status-bar"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; function AppLayout() { useKeyboardShortcuts(); - // Apply persisted appearance preferences (density, reduced motion, font size) - // right after the first paint. The settings page updates these live while - // open; this covers reloads where the settings page was never visited. useEffect(() => { const root = document.documentElement; root.classList.remove("density-compact", "density-spacious", "reduce-motion"); @@ -38,9 +37,11 @@ function AppLayout() { > + + ); } diff --git a/apps/web/src/routes/_app/calendar.tsx b/apps/web/src/routes/_app/calendar.tsx index 7a1956a..5fcf421 100644 --- a/apps/web/src/routes/_app/calendar.tsx +++ b/apps/web/src/routes/_app/calendar.tsx @@ -171,6 +171,7 @@ function CalendarPage() { const [selectedEvent, setSelectedEvent] = useState(null); const [eventDetailOpen, setEventDetailOpen] = useState(false); const [isMobile, setIsMobile] = useState(false); + const [selectedEventIndex, setSelectedEventIndex] = useState(-1); useRealtime({ enabled: true }); @@ -183,7 +184,6 @@ function CalendarPage() { return () => window.removeEventListener("resize", check); }, []); - const activeDomainId = useApiDomain(); const [showTasks, setShowTasks] = useState(true); const [showHabits, setShowHabits] = useState(true); @@ -227,6 +227,64 @@ function CalendarPage() { })); }, [events]); + // Keyboard navigation for calendar + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable || + target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') + ) { + return; + } + + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + setDate((d) => addDays(d, -1)); + break; + case "ArrowRight": + e.preventDefault(); + setDate((d) => addDays(d, 1)); + break; + case "ArrowUp": + e.preventDefault(); + setDate((d) => addDays(d, -7)); + break; + case "ArrowDown": + e.preventDefault(); + setDate((d) => addDays(d, 7)); + break; + case "t": + e.preventDefault(); + setDate(new Date()); + break; + case "n": + e.preventDefault(); + setCreateOpen(true); + break; + case "Enter": + if (selectedEventIndex >= 0 && selectedEventIndex < calendarEvents.length) { + e.preventDefault(); + setSelectedEvent(calendarEvents[selectedEventIndex]); + setEventDetailOpen(true); + } + break; + case "Escape": + if (eventDetailOpen) { + setEventDetailOpen(false); + } + break; + } + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [calendarEvents, selectedEventIndex, eventDetailOpen]); + const handleSelectEvent = useCallback((event: any) => { setSelectedEvent(event as CalendarEvent); setEventDetailOpen(true); diff --git a/apps/web/src/routes/_app/tasks.tsx b/apps/web/src/routes/_app/tasks.tsx index 2ec70fa..cc2c4d0 100644 --- a/apps/web/src/routes/_app/tasks.tsx +++ b/apps/web/src/routes/_app/tasks.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { createRoute, useNavigate } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -10,6 +10,7 @@ import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -23,6 +24,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Checkbox } from "@/components/ui/checkbox"; import { LoadingState, EmptyState, ErrorState } from "@/components/state"; import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs"; import { PRIORITY } from "@/lib/status-colors"; @@ -30,6 +32,7 @@ import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types"; import { cn } from "@/lib/utils"; import { parseTaskInput } from "@/lib/nlp"; import { RecurrencePicker } from "@/components/tasks/recurrence-picker"; +import { BulkActionBar } from "@/components/tasks/bulk-action-bar"; const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [ { id: "backlog", label: "Backlog", colorClass: "bg-slate-400" }, @@ -247,6 +250,11 @@ function TasksPage() { const [selectedTask, setSelectedTask] = useState(null); const [panelOpen, setPanelOpen] = useState(false); const [activeId, setActiveId] = useState(null); + const [selectedRowIndex, setSelectedRowIndex] = useState(-1); + const [editingTaskId, setEditingTaskId] = useState(null); + const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()); + const [showKbdHint, setShowKbdHint] = useState(true); + const [editTitle, setEditTitle] = useState(""); useRealtime({ enabled: true }); @@ -299,6 +307,126 @@ function TasksPage() { const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0); const [loadingMoreTasks, setLoadingMoreTasks] = useState(false); + // Keyboard navigation for list view + useEffect(() => { + if (view !== "list") return; + + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable || + target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') + ) { + return; + } + + setShowKbdHint(false); + + const hasSelection = selectedTaskIds.size > 0; + const currentIdx = selectedRowIndex >= 0 ? selectedRowIndex : 0; + + switch (e.key) { + case "j": + if (!e.shiftKey) { + e.preventDefault(); + setSelectedRowIndex((prev) => { + const next = Math.min(prev + 1, tasks.length - 1); + const row = document.querySelector(`[data-row-index="${next}"]`); + row?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + return next; + }); + } + break; + case "k": + if (!e.shiftKey) { + e.preventDefault(); + setSelectedRowIndex((prev) => { + const next = Math.max(prev - 1, 0); + const row = document.querySelector(`[data-row-index="${next}"]`); + row?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + return next; + }); + } + break; + case "J": + if (e.shiftKey) { + e.preventDefault(); + if (tasks.length > 1) { + const newOrder = tasks.map((t) => t.id); + const idx = currentIdx; + const nextIdx = Math.min(idx + 1, tasks.length - 1); + if (idx !== nextIdx) { + [newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]]; + reorderMutation.mutate({ orderedIds: newOrder }); + setSelectedRowIndex(nextIdx); + } + } + } + break; + case "K": + if (e.shiftKey) { + e.preventDefault(); + if (tasks.length > 1) { + const newOrder = tasks.map((t) => t.id); + const idx = currentIdx; + const nextIdx = Math.max(idx - 1, 0); + if (idx !== nextIdx) { + [newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]]; + reorderMutation.mutate({ orderedIds: newOrder }); + setSelectedRowIndex(nextIdx); + } + } + } + break; + case "Enter": + if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) { + e.preventDefault(); + openTaskPanel(tasks[selectedRowIndex]); + } + break; + case "e": + if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) { + e.preventDefault(); + const task = tasks[selectedRowIndex]; + setEditingTaskId(task.id); + setEditTitle(task.title); + } + break; + case "x": + if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) { + e.preventDefault(); + const task = tasks[selectedRowIndex]; + const newStatus = task.status === "done" ? "todo" : "done"; + stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" }); + api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }); + } + break; + case "Escape": + if (hasSelection) { + setSelectedTaskIds(new Set()); + } else { + setSelectedRowIndex(-1); + } + break; + } + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [view, tasks, selectedRowIndex, selectedTaskIds]); + + // Hide kbd hint after 5 seconds + useEffect(() => { + if (!showKbdHint) return; + const timer = setTimeout(() => setShowKbdHint(false), 5000); + return () => clearTimeout(timer); + }, [showKbdHint]); + const loadMoreTasks = async () => { if (!hasMoreTasks || loadingMoreTasks) return; setLoadingMoreTasks(true); @@ -338,6 +466,52 @@ function TasksPage() { }, }); + // Bulk action handlers + const handleBulkComplete = useCallback(() => { + const ids = Array.from(selectedTaskIds); + Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, { status: "done" }))).then(() => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + setSelectedTaskIds(new Set()); + toast.success(`${ids.length} tasks marked as done`); + }); + }, [selectedTaskIds, queryClient]); + + const handleBulkUpdate = useCallback((updates: Partial) => { + const ids = Array.from(selectedTaskIds); + Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, updates))).then(() => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + setSelectedTaskIds(new Set()); + toast.success(`${ids.length} tasks updated`); + }); + }, [selectedTaskIds, queryClient]); + + const handleBulkDelete = useCallback(() => { + const ids = Array.from(selectedTaskIds); + Promise.all(ids.map((id) => api.delete(`/tasks/${id}`))).then(() => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + setSelectedTaskIds(new Set()); + toast.success(`${ids.length} tasks deleted`); + }); + }, [selectedTaskIds, queryClient]); + + const handleSaveEdit = useCallback(() => { + if (editingTaskId && editTitle.trim()) { + api.patch(`/tasks/${editingTaskId}`, { title: editTitle.trim() }).then(() => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + setEditingTaskId(null); + setEditTitle(""); + }); + } + }, [editingTaskId, editTitle, queryClient]); + + const toggleAll = useCallback(() => { + if (selectedTaskIds.size === tasks.length) { + setSelectedTaskIds(new Set()); + } else { + setSelectedTaskIds(new Set(tasks.map((t) => t.id))); + } + }, [selectedTaskIds, tasks]); + const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete("/tasks/" + id), onSuccess: () => { @@ -551,10 +725,26 @@ function TasksPage() { ) : ( -
+
+ {view === "list" && showKbdHint && ( +
+ j/k navigate + Enter open + e edit + x toggle +
+ )} + {selectedTaskIds.size > 0 && ( + + 0} + onCheckedChange={toggleAll} + /> + + )} Title State Priority @@ -565,15 +755,77 @@ function TasksPage() { {tasks.length === 0 ? ( - + 0 ? 6 : 5}> - ) : tasks.map((task) => { + ) : tasks.map((task, idx) => { const st = task.stateId ? stateById.get(task.stateId) : undefined; return ( - openTaskDetail(task)}> - {task.title} + { + if (e.metaKey || e.ctrlKey || e.shiftKey) { + setSelectedTaskIds((prev) => { + const next = new Set(prev); + if (next.has(task.id)) { + next.delete(task.id); + } else { + next.add(task.id); + } + return next; + }); + } else { + openTaskDetail(task); + } + }} + > + {selectedTaskIds.size > 0 && ( + + { + setSelectedTaskIds((prev) => { + const next = new Set(prev); + if (checked) { + next.add(task.id); + } else { + next.delete(task.id); + } + return next; + }); + }} + /> + + )} + + {editingTaskId === task.id ? ( + setEditTitle(e.target.value)} + className="h-7 text-sm" + onBlur={handleSaveEdit} + onKeyDown={(ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleSaveEdit(); + } + if (ev.key === "Escape") { + setEditingTaskId(null); + setEditTitle(""); + } + }} + /> + ) : ( + {task.title} + )} + {st ? ( @@ -607,6 +859,17 @@ function TasksPage() { })}
+ {selectedTaskIds.size > 0 && ( + setSelectedTaskIds(new Set())} + onComplete={handleBulkComplete} + onBatchUpdate={handleBulkUpdate} + onBatchDelete={handleBulkDelete} + /> + )}
)}