Files
ProjectE/apps/web/hooks/use-keyboard-shortcuts.ts
T
mbatchelder eba1d78fb9 feat: Phase 5 - Calendar + Dashboard + Search
Calendar:
- GET /api/domains/[domainId]/calendar/events?from=&to= — returns tasks, habits, projects, milestones
- PATCH /api/domains/[domainId]/tasks/[id]/schedule — drag-to-reschedule with activity feed
- Calendar UI with month/week/day views via react-big-calendar
- Drag-to-reschedule with SSE updates
- Filter by entity type and domain
- Keyboard shortcuts: t=today, m/w/d=view, ←/→=navigate
- Mobile: auto-switches to day view on small screens

Dashboard:
- GET/PUT /api/domains/[domainId]/dashboard — layout stored in domain custom_fields
- 8 per-widget data endpoints (today-tasks, habit-checklist, weekly-stats, project-progress, upcoming-calendar, recent-notes, activity-feed, quick-capture)
- react-grid-layout with responsive breakpoints (12/8/4 cols)
- Drag-to-reorder, resize, add/remove widgets
- Edit mode toggle, per-workspace layout persistence
- Widget error boundary

Search:
- tsvector columns + GIN indexes on tasks, notes, projects, habits, domains
- GET /api/search?q=&types=&domain= — ranked results with ts_headline snippets
- Dedicated search page with grouped results, filters, recent searches (localStorage)
- Empty state with hints

Schema:
- Added custom_fields jsonb column to domains table (migration 0002)
- Removed stale root app/ directory

Build: passes, typecheck: passes, tests: 18/18 wikilink-parser tests pass
2026-07-29 07:32:47 -04:00

155 lines
5.7 KiB
TypeScript

'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useKeyboardShortcutsStore } from '@/lib/stores/use-keyboard-shortcuts-store';
export function useKeyboardShortcuts() {
const router = useRouter();
const { shortcuts, enabled } = useKeyboardShortcutsStore();
useEffect(() => {
if (!enabled) return;
let pendingKey = '';
let pendingTimeout: ReturnType<typeof setTimeout>;
const handleKeyDown = (e: KeyboardEvent) => {
// Never override native behavior inside controls or modal UI.
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.tagName === 'BUTTON' ||
target.isContentEditable ||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
) {
return;
}
// Ignore if modifier keys are pressed (except for our shortcuts)
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.toLowerCase();
// Handle two-key combos (G then letter)
if (pendingKey) {
clearTimeout(pendingTimeout);
pendingKey = '';
if (key === 'd') { router.push('/dashboard'); e.preventDefault(); return; }
if (key === 't') { router.push('/tasks'); e.preventDefault(); return; }
if (key === 'h') { router.push('/habits'); e.preventDefault(); return; }
if (key === 'p') { router.push('/projects'); e.preventDefault(); return; }
if (key === 'n') { router.push('/notes'); e.preventDefault(); return; }
if (key === 'g') { router.push('/graph'); e.preventDefault(); return; }
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
if (key === 's') { router.push('/search'); e.preventDefault(); return; }
if (key === 'a') { router.push('/analytics'); e.preventDefault(); return; }
return;
}
// Single key shortcuts
switch (key) {
case 'g':
pendingKey = 'g';
pendingTimeout = setTimeout(() => { pendingKey = ''; }, 1000);
e.preventDefault();
break;
case '/':
// Focus search
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
e.preventDefault();
break;
case 'n':
// New (context-dependent)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
e.preventDefault();
break;
case 'c': {
// c t — new task (Cmd palette → "New task")
// Check if we're on the tasks page
if (window.location.pathname.startsWith('/tasks')) {
document.dispatchEvent(new CustomEvent('open-create-task', { detail: { status: 'todo' } }));
e.preventDefault();
}
// c h — new habit
if (window.location.pathname.startsWith('/habits')) {
document.dispatchEvent(new CustomEvent('open-create-habit'));
e.preventDefault();
}
// c p — new project
if (window.location.pathname.startsWith('/projects')) {
document.dispatchEvent(new CustomEvent('open-create-project'));
e.preventDefault();
}
// c n — new note
if (window.location.pathname.startsWith('/notes')) {
document.dispatchEvent(new CustomEvent('open-create-note'));
e.preventDefault();
}
// c s — new section (on project detail page)
if (window.location.pathname.match(/^\/projects\/[^/]+$/)) {
document.dispatchEvent(new CustomEvent('open-create-section'));
e.preventDefault();
}
break;
}
case 'e': {
// e — edit selected task (when task is focused)
const focusedTask = document.querySelector('[data-task-id]:focus');
if (focusedTask) {
(focusedTask as HTMLElement).click();
e.preventDefault();
}
break;
}
case 'd': {
// d — delete selected task
const deleteBtn = document.querySelector('[data-delete-task]');
if (deleteBtn) {
(deleteBtn as HTMLElement).click();
e.preventDefault();
}
break;
}
case ' ': {
// Space — open task detail panel
const firstTask = document.querySelector('[data-task-id]');
if (firstTask && window.location.pathname.startsWith('/tasks')) {
(firstTask as HTMLElement).click();
e.preventDefault();
}
break;
}
case 'escape': {
// Esc — close detail panel
const closeBtn = document.querySelector('[data-close-panel]');
if (closeBtn) {
(closeBtn as HTMLElement).click();
e.preventDefault();
}
break;
}
case '1':
case '2':
case '3':
case '4': {
// 1/2/3/4 — filter kanban column
if (window.location.pathname.startsWith('/tasks')) {
const statuses: Record<string, string> = { '1': 'todo', '2': 'in_progress', '3': 'done', '4': 'cancelled' };
document.dispatchEvent(new CustomEvent('filter-kanban', { detail: { status: statuses[key] } }));
e.preventDefault();
}
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [router, enabled, shortcuts]);
}