/** * GoingsOn - Keyboard Shortcuts Module * All keyboard shortcuts, overlay, item navigation */ (function() { 'use strict'; // ============ Keyboard State ============ // State for two-key sequences (like 'g t') let pendingKey = null; let pendingKeyTimeout = null; // Selected item index for j/k navigation let selectedItemIndex = -1; // ============ Keyboard Shortcuts Definition ============ const shortcuts = { // Single key shortcuts '?': { action: showShortcutsOverlay, description: 'Show this help' }, 'Escape': { action: closeShortcutsOverlay, description: 'Close overlay/modal' }, 'q': { action: openQuickAddModal, description: 'Quick add task' }, 'n': { action: newItemForCurrentView, description: 'New item in current view' }, 'j': { action: selectNextItem, description: 'Select next item' }, 'k': { action: selectPrevItem, description: 'Select previous item' }, 'Enter': { action: openSelectedItem, description: 'Open selected item' }, 'a': { action: archiveSelected, description: 'Archive selected (emails)' }, 'c': { action: completeSelected, description: 'Complete selected (tasks)' }, 't': { action: createTaskFromSelected, description: 'Create task from email' }, 'r': { action: replySelected, description: 'Reply to email' }, 'f': { action: forwardSelected, description: 'Forward email' }, 'u': { action: markUnreadSelected, description: 'Mark email unread' }, 's': { action: snoozeSelected, description: 'Snooze selected item' }, 'S': { action: scheduleSelected, description: 'Schedule selected task', shift: true }, '[': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.previousDay(); }, description: 'Previous day (Day Plan)' }, ']': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.nextDay(); }, description: 'Next day (Day Plan)' }, // Two-key sequences (g + key for "go to") 'g': { 't': { action: () => GoingsOn.navigation.switchView('tasks'), description: 'Go to Tasks' }, 'e': { action: () => GoingsOn.navigation.switchView('emails'), description: 'Go to Emails' }, 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' }, 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' }, 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' }, 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' }, 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' }, 'm': { action: () => GoingsOn.navigation.switchView('monthly-review'), description: 'Go to Monthly Review' }, } }; // ============ Keyboard Handler ============ function handleKeyboardShortcut(e) { // Cmd+K / Ctrl+K opens command palette from anywhere (even inputs) if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (GoingsOn.search?.isOpen()) { GoingsOn.search.close(); } else { GoingsOn.search.open(); } return; } // Ignore if typing in an input const target = e.target; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { return; } // Ignore if command palette is open if (GoingsOn.search?.isOpen()) return; // Ignore if modal is open (except Escape) const modalOpen = !document.getElementById('modal-overlay').classList.contains('hidden'); const shortcutsOverlayOpen = !document.getElementById('shortcuts-overlay')?.classList.contains('hidden'); if (e.key === 'Escape') { if (GoingsOn.search?.isOpen()) { GoingsOn.search.close(); return; } if (shortcutsOverlayOpen) { closeShortcutsOverlay(); return; } if (modalOpen) { GoingsOn.ui.closeModal(); return; } return; } // Don't process other shortcuts if modal is open if (modalOpen || shortcutsOverlayOpen) { return; } // Handle two-key sequences if (pendingKey) { clearTimeout(pendingKeyTimeout); const sequence = shortcuts[pendingKey]; if (sequence && typeof sequence === 'object' && sequence[e.key]) { e.preventDefault(); sequence[e.key].action(); } pendingKey = null; dismissPendingKeyHint(); return; } // Check if this starts a two-key sequence const shortcut = shortcuts[e.key]; if (shortcut) { // Check if this shortcut requires shift if (shortcut.shift && !e.shiftKey) { // Shortcut requires shift but shift not pressed, skip return; } if (!shortcut.shift && e.shiftKey && e.key !== '?') { // Shortcut doesn't require shift but shift is pressed (except for ?) // Check if uppercase version exists const upperKey = e.key.toUpperCase(); if (shortcuts[upperKey] && shortcuts[upperKey].shift) { e.preventDefault(); shortcuts[upperKey].action(); return; } } if (typeof shortcut.action === 'function') { e.preventDefault(); shortcut.action(); } else if (typeof shortcut === 'object' && !shortcut.action) { // This is a two-key sequence starter e.preventDefault(); pendingKey = e.key; showPendingKeyHint(e.key, shortcut); pendingKeyTimeout = setTimeout(() => { pendingKey = null; dismissPendingKeyHint(); }, 1000); } } } // ============ Pending Key Hint ============ /** * Show a small hint when a two-key sequence is started (e.g. pressing 'g'). * Lists the available follow-up keys so the user knows what to press next. */ function showPendingKeyHint(key, sequence) { dismissPendingKeyHint(); const hint = document.createElement('div'); hint.id = 'pending-key-hint'; hint.className = 'pending-key-hint'; const destinations = Object.entries(sequence) .map(([k, v]) => `${k} ${GoingsOn.utils.escapeHtml(v.description.replace('Go to ', ''))}`) .join('  ·  '); hint.innerHTML = `Go to: ${destinations}`; document.body.appendChild(hint); } function dismissPendingKeyHint() { const hint = document.getElementById('pending-key-hint'); if (hint) hint.remove(); } // ============ Shortcuts Overlay ============ function showShortcutsOverlay() { let overlay = document.getElementById('shortcuts-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'shortcuts-overlay'; overlay.className = 'shortcuts-overlay'; overlay.innerHTML = `

Keyboard Shortcuts

WORK

g t Tasks
g p Projects

TIME

g d Day
g w Week
g m Month
g v Events

MESSAGES

g e Email
g c Contacts

MOVEMENT

[ Previous day
] Next day
j Next item
k Previous item
Enter Open selected

ACTIONS

K Command palette
n New item
q Quick add task
c Complete task
r Reply to email
f Forward email
u Mark unread
a Archive email
t Email to task
s Snooze item
Shift S Schedule task
? Show this help
Esc Close modal
`; overlay.addEventListener('click', (e) => { if (e.target === overlay) closeShortcutsOverlay(); }); document.body.appendChild(overlay); } else { overlay.classList.remove('hidden'); } } function closeShortcutsOverlay() { const overlay = document.getElementById('shortcuts-overlay'); if (overlay) { overlay.classList.add('hidden'); } } /** * Toggle the keyboard shortcuts overlay open/closed. */ function toggleKeyboardShortcutsOverlay() { const overlay = document.getElementById('shortcuts-overlay'); if (overlay && !overlay.classList.contains('hidden')) { closeShortcutsOverlay(); } else { showShortcutsOverlay(); } } // ============ Quick Add Modal ============ function openQuickAddModal() { const content = `
@ project # tag + H/M/L priority due: tomorrow, friday, +3d
`; GoingsOn.ui.openModal('Quick Add', content); } /** * Highlight the syntax hint matching what the user is currently typing. * @param {HTMLInputElement} input - The quick-add text input */ function onQuickAddInput(input) { const syntaxEl = document.getElementById('quick-add-syntax'); if (!syntaxEl) return; const val = input.value; // Find the token the cursor is currently inside const cursor = input.selectionStart || val.length; const beforeCursor = val.slice(0, cursor); const lastWord = beforeCursor.split(/\s/).pop() || ''; const activeToken = lastWord.startsWith('@') ? '@' : lastWord.startsWith('#') ? '#' : lastWord.startsWith('+') ? '+' : lastWord.startsWith('due:') ? 'due:' : null; for (const span of syntaxEl.querySelectorAll('[data-token]')) { const isActive = span.dataset.token === activeToken; span.style.opacity = activeToken ? (isActive ? '1' : '0.4') : '1'; span.style.fontWeight = isActive ? '600' : 'normal'; } } /** * Submit the quick-add task form. * @param {Event} e - Form submit event */ async function submitQuickAdd(e) { e.preventDefault(); const form = e.target; const text = form.text.value.trim(); if (!text) return; try { await GoingsOn.api.tasks.quickAdd(text); GoingsOn.ui.showToast('Task created!', 'success'); GoingsOn.ui.closeModal(); if (GoingsOn.navigation.getCurrentView() === 'tasks') { GoingsOn.tasks.load(); } } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error'); } } function newItemForCurrentView() { const currentView = GoingsOn.navigation.getCurrentView(); switch (currentView) { case 'projects': GoingsOn.projects.openNew(); break; case 'tasks': GoingsOn.tasks.openNew(); break; case 'events': GoingsOn.events.openNew(); break; case 'emails': GoingsOn.emails.openCompose(); break; case 'contacts': GoingsOn.contacts.openNew(); break; } } // ============ Item Navigation ============ function getSelectableItems() { const currentView = GoingsOn.navigation.getCurrentView(); switch (currentView) { case 'tasks': return document.querySelectorAll('#task-list-container .task-row[data-id]'); case 'emails': return document.querySelectorAll('#email-list .email-item'); case 'projects': return document.querySelectorAll('#projects-grid .project-card'); case 'events': return document.querySelectorAll('#event-list-container .event-row-virtual'); default: return []; } } function clearItemSelection() { document.querySelectorAll('.keyboard-selected').forEach(el => { el.classList.remove('keyboard-selected'); }); } function selectNextItem() { const items = getSelectableItems(); if (items.length === 0) return; clearItemSelection(); selectedItemIndex = Math.min(selectedItemIndex + 1, items.length - 1); if (selectedItemIndex < 0) selectedItemIndex = 0; items[selectedItemIndex].classList.add('keyboard-selected'); items[selectedItemIndex].scrollIntoView({ block: 'nearest' }); } function selectPrevItem() { const items = getSelectableItems(); if (items.length === 0) return; clearItemSelection(); selectedItemIndex = Math.max(selectedItemIndex - 1, 0); items[selectedItemIndex].classList.add('keyboard-selected'); items[selectedItemIndex].scrollIntoView({ block: 'nearest' }); } function openSelectedItem() { const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return; const item = items[selectedItemIndex]; item.click(); } function archiveSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'emails') return; const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return; const item = items[selectedItemIndex]; const id = item.dataset.id; if (id) { GoingsOn.emails.archive(id); } } function completeSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'tasks') return; const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return; const item = items[selectedItemIndex]; const id = item.dataset.id; if (id) { GoingsOn.tasks.complete(id); } } async function createTaskFromSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'emails') { GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info'); return; } const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info'); return; } const item = items[selectedItemIndex]; const id = item.dataset.id; if (id) { GoingsOn.emails.createTaskFromEmail(id); } } function replySelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'emails') return; const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info'); return; } const id = items[selectedItemIndex].dataset.id; if (id) GoingsOn.emails.reply(id); } function forwardSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'emails') return; const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info'); return; } const id = items[selectedItemIndex].dataset.id; if (id) GoingsOn.emails.forward(id); } function markUnreadSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'emails') return; const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info'); return; } const id = items[selectedItemIndex].dataset.id; if (id) GoingsOn.emails.markUnread(id); } function snoozeSelected() { const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select an item first (use j/k to navigate)', 'info'); return; } const item = items[selectedItemIndex]; const currentView = GoingsOn.navigation.getCurrentView(); const id = item.dataset.id; if (currentView === 'tasks' && id) { GoingsOn.snooze.openModal('task', id); } else if (currentView === 'emails' && id) { GoingsOn.snooze.openModal('email', id); } else { GoingsOn.ui.showToast('Snooze is available for tasks and emails', 'info'); } } function scheduleSelected() { const currentView = GoingsOn.navigation.getCurrentView(); if (currentView !== 'tasks') { GoingsOn.ui.showToast('Schedule is available for tasks only', 'info'); return; } const items = getSelectableItems(); if (selectedItemIndex < 0 || selectedItemIndex >= items.length) { GoingsOn.ui.showToast('Select a task first (use j/k to navigate)', 'info'); return; } const item = items[selectedItemIndex]; const id = item.dataset.id; if (id && GoingsOn.dayPlan?.openScheduleTaskModal) { GoingsOn.dayPlan.openScheduleTaskModal(id); } } /** * Reset the keyboard selection index and clear any visual highlight. */ function resetSelectedItemIndex() { selectedItemIndex = -1; clearItemSelection(); } // ============ Style for keyboard-selected ============ const keyboardStyle = document.createElement('style'); keyboardStyle.textContent = ` .keyboard-selected { outline: 2px solid var(--accent-primary) !important; outline-offset: -2px; } `; document.head.appendChild(keyboardStyle); // ============ Register Event Listeners ============ // Register keyboard shortcut handler (skip on touch devices — no physical keyboard) if (!GoingsOn.touch?.isTouchDevice) { document.addEventListener('keydown', handleKeyboardShortcut); } // ============ Populate GoingsOn.keyboard Namespace ============ GoingsOn.keyboard = { showShortcuts: showShortcutsOverlay, closeShortcuts: closeShortcutsOverlay, toggleShortcuts: toggleKeyboardShortcutsOverlay, openQuickAdd: openQuickAddModal, submitQuickAdd: submitQuickAdd, resetSelection: resetSelectedItemIndex, _onQuickAddInput: onQuickAddInput, }; })();