Skip to main content

max / goingson

22.9 KB · 568 lines History Blame Raw
1 /**
2 * GoingsOn - Keyboard Shortcuts Module
3 * All keyboard shortcuts, overlay, item navigation
4 */
5
6 (function() {
7 'use strict';
8
9 // ============ Keyboard State ============
10
11 // State for two-key sequences (like 'g t')
12 let pendingKey = null;
13 let pendingKeyTimeout = null;
14
15 // Selected item index for j/k navigation
16 let selectedItemIndex = -1;
17
18 // ============ Keyboard Shortcuts Definition ============
19
20 const shortcuts = {
21 // Single key shortcuts
22 '?': { action: showShortcutsOverlay, description: 'Show this help' },
23 'Escape': { action: closeShortcutsOverlay, description: 'Close overlay/modal' },
24 'q': { action: openQuickAddModal, description: 'Quick add task' },
25 'n': { action: newItemForCurrentView, description: 'New item in current view' },
26 'j': { action: selectNextItem, description: 'Select next item' },
27 'k': { action: selectPrevItem, description: 'Select previous item' },
28 'Enter': { action: openSelectedItem, description: 'Open selected item' },
29 'a': { action: archiveSelected, description: 'Archive selected (emails)' },
30 'c': { action: completeSelected, description: 'Complete selected (tasks)' },
31 't': { action: createTaskFromSelected, description: 'Create task from email' },
32 'r': { action: replySelected, description: 'Reply to email' },
33 'f': { action: forwardSelected, description: 'Forward email' },
34 'u': { action: markUnreadSelected, description: 'Mark email unread' },
35 's': { action: snoozeSelected, description: 'Snooze selected item' },
36 'S': { action: scheduleSelected, description: 'Schedule selected task', shift: true },
37 '[': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.previousDay(); }, description: 'Previous day (Day Plan)' },
38 ']': { action: () => { if (GoingsOn.navigation.getCurrentView() === 'day-plan') GoingsOn.dayPlan.nextDay(); }, description: 'Next day (Day Plan)' },
39
40 // Two-key sequences (g + key for "go to")
41 'g': {
42 't': { action: () => GoingsOn.navigation.switchView('tasks'), description: 'Go to Tasks' },
43 'e': { action: () => GoingsOn.navigation.switchView('emails'), description: 'Go to Emails' },
44 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' },
45 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' },
46 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' },
47 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' },
48 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' },
49 'm': { action: () => GoingsOn.navigation.switchView('monthly-review'), description: 'Go to Monthly Review' },
50 }
51 };
52
53 // ============ Keyboard Handler ============
54
55 function handleKeyboardShortcut(e) {
56 // Cmd+K / Ctrl+K opens command palette from anywhere (even inputs)
57 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
58 e.preventDefault();
59 if (GoingsOn.search?.isOpen()) {
60 GoingsOn.search.close();
61 } else {
62 GoingsOn.search.open();
63 }
64 return;
65 }
66
67 // Ignore if typing in an input
68 const target = e.target;
69 if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
70 return;
71 }
72
73 // Ignore if command palette is open
74 if (GoingsOn.search?.isOpen()) return;
75
76 // Ignore if modal is open (except Escape)
77 const modalOpen = !document.getElementById('modal-overlay').classList.contains('hidden');
78 const shortcutsOverlayOpen = !document.getElementById('shortcuts-overlay')?.classList.contains('hidden');
79
80 if (e.key === 'Escape') {
81 if (GoingsOn.search?.isOpen()) {
82 GoingsOn.search.close();
83 return;
84 }
85 if (shortcutsOverlayOpen) {
86 closeShortcutsOverlay();
87 return;
88 }
89 if (modalOpen) {
90 GoingsOn.ui.closeModal();
91 return;
92 }
93 return;
94 }
95
96 // Don't process other shortcuts if modal is open
97 if (modalOpen || shortcutsOverlayOpen) {
98 return;
99 }
100
101 // Handle two-key sequences
102 if (pendingKey) {
103 clearTimeout(pendingKeyTimeout);
104 const sequence = shortcuts[pendingKey];
105 if (sequence && typeof sequence === 'object' && sequence[e.key]) {
106 e.preventDefault();
107 sequence[e.key].action();
108 }
109 pendingKey = null;
110 dismissPendingKeyHint();
111 return;
112 }
113
114 // Check if this starts a two-key sequence
115 const shortcut = shortcuts[e.key];
116 if (shortcut) {
117 // Check if this shortcut requires shift
118 if (shortcut.shift && !e.shiftKey) {
119 // Shortcut requires shift but shift not pressed, skip
120 return;
121 }
122 if (!shortcut.shift && e.shiftKey && e.key !== '?') {
123 // Shortcut doesn't require shift but shift is pressed (except for ?)
124 // Check if uppercase version exists
125 const upperKey = e.key.toUpperCase();
126 if (shortcuts[upperKey] && shortcuts[upperKey].shift) {
127 e.preventDefault();
128 shortcuts[upperKey].action();
129 return;
130 }
131 }
132 if (typeof shortcut.action === 'function') {
133 e.preventDefault();
134 shortcut.action();
135 } else if (typeof shortcut === 'object' && !shortcut.action) {
136 // This is a two-key sequence starter
137 e.preventDefault();
138 pendingKey = e.key;
139 showPendingKeyHint(e.key, shortcut);
140 pendingKeyTimeout = setTimeout(() => {
141 pendingKey = null;
142 dismissPendingKeyHint();
143 }, 1000);
144 }
145 }
146 }
147
148 // ============ Pending Key Hint ============
149
150 /**
151 * Show a small hint when a two-key sequence is started (e.g. pressing 'g').
152 * Lists the available follow-up keys so the user knows what to press next.
153 */
154 function showPendingKeyHint(key, sequence) {
155 dismissPendingKeyHint();
156 const hint = document.createElement('div');
157 hint.id = 'pending-key-hint';
158 hint.className = 'pending-key-hint';
159
160 const destinations = Object.entries(sequence)
161 .map(([k, v]) => `<kbd>${k}</kbd> ${GoingsOn.utils.escapeHtml(v.description.replace('Go to ', ''))}`)
162 .join('&nbsp;&nbsp;&middot;&nbsp;&nbsp;');
163
164 hint.innerHTML = `<span class="pending-key-hint-label">Go to:</span> ${destinations}`;
165 document.body.appendChild(hint);
166 }
167
168 function dismissPendingKeyHint() {
169 const hint = document.getElementById('pending-key-hint');
170 if (hint) hint.remove();
171 }
172
173 // ============ Shortcuts Overlay ============
174
175 function showShortcutsOverlay() {
176 let overlay = document.getElementById('shortcuts-overlay');
177 if (!overlay) {
178 overlay = document.createElement('div');
179 overlay.id = 'shortcuts-overlay';
180 overlay.className = 'shortcuts-overlay';
181 overlay.innerHTML = `
182 <div class="shortcuts-overlay-panel">
183 <h2 class="shortcuts-title">Keyboard Shortcuts</h2>
184
185 <div class="shortcuts-grid">
186 <div>
187 <h3 class="shortcuts-group-heading">WORK</h3>
188 <div class="shortcut-row"><kbd>g</kbd> <kbd>t</kbd> <span>Tasks</span></div>
189 <div class="shortcut-row"><kbd>g</kbd> <kbd>p</kbd> <span>Projects</span></div>
190 <h3 class="shortcuts-group-heading-spaced">TIME</h3>
191 <div class="shortcut-row"><kbd>g</kbd> <kbd>d</kbd> <span>Day</span></div>
192 <div class="shortcut-row"><kbd>g</kbd> <kbd>w</kbd> <span>Week</span></div>
193 <div class="shortcut-row"><kbd>g</kbd> <kbd>m</kbd> <span>Month</span></div>
194 <div class="shortcut-row"><kbd>g</kbd> <kbd>v</kbd> <span>Events</span></div>
195 <h3 class="shortcuts-group-heading-spaced">MESSAGES</h3>
196 <div class="shortcut-row"><kbd>g</kbd> <kbd>e</kbd> <span>Email</span></div>
197 <div class="shortcut-row"><kbd>g</kbd> <kbd>c</kbd> <span>Contacts</span></div>
198 <h3 class="shortcuts-group-heading-spaced">MOVEMENT</h3>
199 <div class="shortcut-row"><kbd>[</kbd> <span>Previous day</span></div>
200 <div class="shortcut-row"><kbd>]</kbd> <span>Next day</span></div>
201 <div class="shortcut-row"><kbd>j</kbd> <span>Next item</span></div>
202 <div class="shortcut-row"><kbd>k</kbd> <span>Previous item</span></div>
203 <div class="shortcut-row"><kbd>Enter</kbd> <span>Open selected</span></div>
204 </div>
205
206 <div>
207 <h3 class="shortcuts-group-heading">ACTIONS</h3>
208 <div class="shortcut-row"><kbd>&#8984;</kbd> <kbd>K</kbd> <span>Command palette</span></div>
209 <div class="shortcut-row"><kbd>n</kbd> <span>New item</span></div>
210 <div class="shortcut-row"><kbd>q</kbd> <span>Quick add task</span></div>
211 <div class="shortcut-row"><kbd>c</kbd> <span>Complete task</span></div>
212 <div class="shortcut-row"><kbd>r</kbd> <span>Reply to email</span></div>
213 <div class="shortcut-row"><kbd>f</kbd> <span>Forward email</span></div>
214 <div class="shortcut-row"><kbd>u</kbd> <span>Mark unread</span></div>
215 <div class="shortcut-row"><kbd>a</kbd> <span>Archive email</span></div>
216 <div class="shortcut-row"><kbd>t</kbd> <span>Email to task</span></div>
217 <div class="shortcut-row"><kbd>s</kbd> <span>Snooze item</span></div>
218 <div class="shortcut-row"><kbd>Shift</kbd> <kbd>S</kbd> <span>Schedule task</span></div>
219 <div class="shortcut-row"><kbd>?</kbd> <span>Show this help</span></div>
220 <div class="shortcut-row"><kbd>Esc</kbd> <span>Close modal</span></div>
221 </div>
222 </div>
223
224 <div class="shortcuts-close">
225 <button class="btn btn-secondary" onclick="GoingsOn.keyboard.closeShortcuts()">Close (Esc)</button>
226 </div>
227 </div>
228 `;
229
230 overlay.addEventListener('click', (e) => {
231 if (e.target === overlay) closeShortcutsOverlay();
232 });
233
234 document.body.appendChild(overlay);
235 } else {
236 overlay.classList.remove('hidden');
237 }
238 }
239
240 function closeShortcutsOverlay() {
241 const overlay = document.getElementById('shortcuts-overlay');
242 if (overlay) {
243 overlay.classList.add('hidden');
244 }
245 }
246
247 /**
248 * Toggle the keyboard shortcuts overlay open/closed.
249 */
250 function toggleKeyboardShortcutsOverlay() {
251 const overlay = document.getElementById('shortcuts-overlay');
252 if (overlay && !overlay.classList.contains('hidden')) {
253 closeShortcutsOverlay();
254 } else {
255 showShortcutsOverlay();
256 }
257 }
258
259 // ============ Quick Add Modal ============
260
261 function openQuickAddModal() {
262 const content = `
263 <form id="quick-add-form" onsubmit="GoingsOn.keyboard.submitQuickAdd(event)">
264 <div class="form-group">
265 <label class="form-label">Quick Add Task</label>
266 <input type="text" class="form-input" name="text" required
267 placeholder="Fix login bug @backend #urgent +H due:friday"
268 autofocus
269 oninput="GoingsOn.keyboard._onQuickAddInput(this)">
270 <div id="quick-add-syntax" class="quick-add-syntax">
271 <span data-token="@"><kbd>@</kbd> project</span>
272 <span data-token="#"><kbd>#</kbd> tag</span>
273 <span data-token="+"><kbd>+</kbd> H/M/L priority</span>
274 <span data-token="due:"><kbd>due:</kbd> tomorrow, friday, +3d</span>
275 </div>
276 </div>
277 <div class="form-actions">
278 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
279 <button type="submit" class="btn btn-primary">Add Task</button>
280 </div>
281 </form>
282 `;
283 GoingsOn.ui.openModal('Quick Add', content);
284 }
285
286 /**
287 * Highlight the syntax hint matching what the user is currently typing.
288 * @param {HTMLInputElement} input - The quick-add text input
289 */
290 function onQuickAddInput(input) {
291 const syntaxEl = document.getElementById('quick-add-syntax');
292 if (!syntaxEl) return;
293 const val = input.value;
294 // Find the token the cursor is currently inside
295 const cursor = input.selectionStart || val.length;
296 const beforeCursor = val.slice(0, cursor);
297 const lastWord = beforeCursor.split(/\s/).pop() || '';
298 const activeToken = lastWord.startsWith('@') ? '@'
299 : lastWord.startsWith('#') ? '#'
300 : lastWord.startsWith('+') ? '+'
301 : lastWord.startsWith('due:') ? 'due:'
302 : null;
303
304 for (const span of syntaxEl.querySelectorAll('[data-token]')) {
305 const isActive = span.dataset.token === activeToken;
306 span.style.opacity = activeToken ? (isActive ? '1' : '0.4') : '1';
307 span.style.fontWeight = isActive ? '600' : 'normal';
308 }
309 }
310
311 /**
312 * Submit the quick-add task form.
313 * @param {Event} e - Form submit event
314 */
315 async function submitQuickAdd(e) {
316 e.preventDefault();
317 const form = e.target;
318 const text = form.text.value.trim();
319 if (!text) return;
320
321 try {
322 await GoingsOn.api.tasks.quickAdd(text);
323 GoingsOn.ui.showToast('Task created!', 'success');
324 GoingsOn.ui.closeModal();
325 if (GoingsOn.navigation.getCurrentView() === 'tasks') {
326 GoingsOn.tasks.load();
327 }
328 } catch (err) {
329 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error');
330 }
331 }
332
333 function newItemForCurrentView() {
334 const currentView = GoingsOn.navigation.getCurrentView();
335 switch (currentView) {
336 case 'projects': GoingsOn.projects.openNew(); break;
337 case 'tasks': GoingsOn.tasks.openNew(); break;
338 case 'events': GoingsOn.events.openNew(); break;
339 case 'emails': GoingsOn.emails.openCompose(); break;
340 case 'contacts': GoingsOn.contacts.openNew(); break;
341 }
342 }
343
344 // ============ Item Navigation ============
345
346 function getSelectableItems() {
347 const currentView = GoingsOn.navigation.getCurrentView();
348 switch (currentView) {
349 case 'tasks':
350 return document.querySelectorAll('#task-list-container .task-row[data-id]');
351 case 'emails':
352 return document.querySelectorAll('#email-list .email-item');
353 case 'projects':
354 return document.querySelectorAll('#projects-grid .project-card');
355 case 'events':
356 return document.querySelectorAll('#event-list-container .event-row-virtual');
357 default:
358 return [];
359 }
360 }
361
362 function clearItemSelection() {
363 document.querySelectorAll('.keyboard-selected').forEach(el => {
364 el.classList.remove('keyboard-selected');
365 });
366 }
367
368 function selectNextItem() {
369 const items = getSelectableItems();
370 if (items.length === 0) return;
371
372 clearItemSelection();
373 selectedItemIndex = Math.min(selectedItemIndex + 1, items.length - 1);
374 if (selectedItemIndex < 0) selectedItemIndex = 0;
375
376 items[selectedItemIndex].classList.add('keyboard-selected');
377 items[selectedItemIndex].scrollIntoView({ block: 'nearest' });
378 }
379
380 function selectPrevItem() {
381 const items = getSelectableItems();
382 if (items.length === 0) return;
383
384 clearItemSelection();
385 selectedItemIndex = Math.max(selectedItemIndex - 1, 0);
386
387 items[selectedItemIndex].classList.add('keyboard-selected');
388 items[selectedItemIndex].scrollIntoView({ block: 'nearest' });
389 }
390
391 function openSelectedItem() {
392 const items = getSelectableItems();
393 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
394
395 const item = items[selectedItemIndex];
396 item.click();
397 }
398
399 function archiveSelected() {
400 const currentView = GoingsOn.navigation.getCurrentView();
401 if (currentView !== 'emails') return;
402
403 const items = getSelectableItems();
404 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
405
406 const item = items[selectedItemIndex];
407 const id = item.dataset.id;
408 if (id) {
409 GoingsOn.emails.archive(id);
410 }
411 }
412
413 function completeSelected() {
414 const currentView = GoingsOn.navigation.getCurrentView();
415 if (currentView !== 'tasks') return;
416
417 const items = getSelectableItems();
418 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) return;
419
420 const item = items[selectedItemIndex];
421 const id = item.dataset.id;
422 if (id) {
423 GoingsOn.tasks.complete(id);
424 }
425 }
426
427 async function createTaskFromSelected() {
428 const currentView = GoingsOn.navigation.getCurrentView();
429 if (currentView !== 'emails') {
430 GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
431 return;
432 }
433
434 const items = getSelectableItems();
435 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
436 GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
437 return;
438 }
439
440 const item = items[selectedItemIndex];
441 const id = item.dataset.id;
442 if (id) {
443 GoingsOn.emails.createTaskFromEmail(id);
444 }
445 }
446
447 function replySelected() {
448 const currentView = GoingsOn.navigation.getCurrentView();
449 if (currentView !== 'emails') return;
450
451 const items = getSelectableItems();
452 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
453 GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
454 return;
455 }
456
457 const id = items[selectedItemIndex].dataset.id;
458 if (id) GoingsOn.emails.reply(id);
459 }
460
461 function forwardSelected() {
462 const currentView = GoingsOn.navigation.getCurrentView();
463 if (currentView !== 'emails') return;
464
465 const items = getSelectableItems();
466 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
467 GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
468 return;
469 }
470
471 const id = items[selectedItemIndex].dataset.id;
472 if (id) GoingsOn.emails.forward(id);
473 }
474
475 function markUnreadSelected() {
476 const currentView = GoingsOn.navigation.getCurrentView();
477 if (currentView !== 'emails') return;
478
479 const items = getSelectableItems();
480 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
481 GoingsOn.ui.showToast('Select an email first (use j/k to navigate)', 'info');
482 return;
483 }
484
485 const id = items[selectedItemIndex].dataset.id;
486 if (id) GoingsOn.emails.markUnread(id);
487 }
488
489 function snoozeSelected() {
490 const items = getSelectableItems();
491 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
492 GoingsOn.ui.showToast('Select an item first (use j/k to navigate)', 'info');
493 return;
494 }
495
496 const item = items[selectedItemIndex];
497 const currentView = GoingsOn.navigation.getCurrentView();
498 const id = item.dataset.id;
499
500 if (currentView === 'tasks' && id) {
501 GoingsOn.snooze.openModal('task', id);
502 } else if (currentView === 'emails' && id) {
503 GoingsOn.snooze.openModal('email', id);
504 } else {
505 GoingsOn.ui.showToast('Snooze is available for tasks and emails', 'info');
506 }
507 }
508
509 function scheduleSelected() {
510 const currentView = GoingsOn.navigation.getCurrentView();
511 if (currentView !== 'tasks') {
512 GoingsOn.ui.showToast('Schedule is available for tasks only', 'info');
513 return;
514 }
515
516 const items = getSelectableItems();
517 if (selectedItemIndex < 0 || selectedItemIndex >= items.length) {
518 GoingsOn.ui.showToast('Select a task first (use j/k to navigate)', 'info');
519 return;
520 }
521
522 const item = items[selectedItemIndex];
523 const id = item.dataset.id;
524 if (id && GoingsOn.dayPlan?.openScheduleTaskModal) {
525 GoingsOn.dayPlan.openScheduleTaskModal(id);
526 }
527 }
528
529 /**
530 * Reset the keyboard selection index and clear any visual highlight.
531 */
532 function resetSelectedItemIndex() {
533 selectedItemIndex = -1;
534 clearItemSelection();
535 }
536
537 // ============ Style for keyboard-selected ============
538
539 const keyboardStyle = document.createElement('style');
540 keyboardStyle.textContent = `
541 .keyboard-selected {
542 outline: 2px solid var(--accent-primary) !important;
543 outline-offset: -2px;
544 }
545 `;
546 document.head.appendChild(keyboardStyle);
547
548 // ============ Register Event Listeners ============
549
550 // Register keyboard shortcut handler (skip on touch devices — no physical keyboard)
551 if (!GoingsOn.touch?.isTouchDevice) {
552 document.addEventListener('keydown', handleKeyboardShortcut);
553 }
554
555 // ============ Populate GoingsOn.keyboard Namespace ============
556
557 GoingsOn.keyboard = {
558 showShortcuts: showShortcutsOverlay,
559 closeShortcuts: closeShortcutsOverlay,
560 toggleShortcuts: toggleKeyboardShortcutsOverlay,
561 openQuickAdd: openQuickAddModal,
562 submitQuickAdd: submitQuickAdd,
563 resetSelection: resetSelectedItemIndex,
564 _onQuickAddInput: onQuickAddInput,
565 };
566
567 })();
568