Skip to main content

max / goingson

17.1 KB · 533 lines History Blame Raw
1 /**
2 * GoingsOn - Navigation Module
3 * View switching, window title management, tab navigation, mobile tab bar
4 */
5
6 (function() {
7 'use strict';
8
9 // ============ Tab Group Mappings ============
10
11 const TAB_GROUPS = {
12 'tasks': 'work',
13 'projects': 'work',
14 'project-dashboard': 'work',
15 'task-overview': 'work',
16 'day-plan': 'time',
17 'weekly-review': 'time',
18 'monthly-review': 'time',
19 'timer': 'time',
20 'events': 'time',
21 'emails': 'messages',
22 'contacts': 'messages',
23 'contact-dashboard': 'messages',
24 };
25
26 const TAB_DEFAULTS = {
27 'work': 'tasks',
28 'time': 'day-plan',
29 'messages': 'emails',
30 };
31
32 const VIEW_LABELS = {
33 'work': 'Work',
34 'time': 'Time',
35 'messages': 'Messages',
36 'tasks': 'Tasks',
37 'projects': 'Projects',
38 'emails': 'Email',
39 'contacts': 'Contacts',
40 'day-plan': 'Day',
41 'weekly-review': 'Week',
42 'monthly-review': 'Month',
43 'timer': 'Timer',
44 'contact-dashboard': 'Contact',
45 };
46
47 // ============ View Navigation ============
48
49 // Tab click handlers
50 document.querySelectorAll('.tab-navigation .tab').forEach(tab => {
51 tab.addEventListener('click', (e) => {
52 e.preventDefault();
53 const view = tab.dataset.view;
54 switchView(view);
55 });
56 });
57
58 // Pill click handlers
59 document.querySelectorAll('.pill-nav .pill').forEach(pill => {
60 pill.addEventListener('click', () => {
61 switchView(pill.dataset.subview);
62 });
63 });
64
65 // Global keyboard handler for interactive elements with role="button" or role="row"
66 // This enables keyboard activation (Enter/Space) for clickable divs, rows, etc.
67 document.addEventListener('keydown', (e) => {
68 if (e.key === 'Enter' || e.key === ' ') {
69 const target = e.target;
70 const role = target.getAttribute('role');
71 if (role === 'button' || role === 'row' || role === 'listitem') {
72 // Don't interfere with actual buttons or form inputs
73 if (target.tagName !== 'BUTTON' && target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
74 e.preventDefault();
75 target.click();
76 }
77 }
78 }
79 });
80
81 /**
82 * Switch the active view and load its data.
83 * Handles tab/pill state, URL routing, and mobile tab bar.
84 * @param {string} view - View name or tab alias (e.g., 'tasks', 'work', 'emails')
85 */
86 function switchView(view) {
87 // If view is a tab name, resolve to the last-used sub-view for that tab,
88 // falling back to the hardcoded default on first visit of the session.
89 if (TAB_DEFAULTS[view]) {
90 const remembered = GoingsOn.state.lastSubviewByTab?.[view];
91 view = remembered || TAB_DEFAULTS[view];
92 }
93
94 // Cleanup previous view resources before switching
95 cleanupView(GoingsOn.state.currentView);
96
97 // Track previous view for back navigation (e.g., Settings back button)
98 if (GoingsOn.state.currentView && GoingsOn.state.currentView !== view) {
99 GoingsOn.state.previousView = GoingsOn.state.currentView;
100 }
101
102 GoingsOn.state.set('currentView', view);
103
104 // Track view switch counts
105 const c = GoingsOn.state.viewSwitchCounts || {};
106 c[view] = (c[view] || 0) + 1;
107 GoingsOn.state.viewSwitchCounts = c;
108
109 // Determine parent tab
110 const parentTab = TAB_GROUPS[view];
111
112 // Remember this as the last-used sub-view for its parent tab so a
113 // subsequent tap on the mobile tab returns here instead of the default.
114 if (parentTab) {
115 const m = GoingsOn.state.lastSubviewByTab || {};
116 m[parentTab] = view;
117 GoingsOn.state.lastSubviewByTab = m;
118 }
119
120 // Update tab active state and aria-selected
121 document.querySelectorAll('.tab-navigation .tab').forEach(t => {
122 t.classList.remove('active');
123 t.setAttribute('aria-selected', 'false');
124 });
125 if (parentTab) {
126 const activeTab = document.querySelector(`.tab-navigation [data-view="${parentTab}"]`);
127 if (activeTab) {
128 activeTab.classList.add('active');
129 activeTab.setAttribute('aria-selected', 'true');
130 }
131 }
132
133 // Hide all views (tab groups and standalone views like settings)
134 document.querySelectorAll('.view.tab-group').forEach(v => v.classList.add('hidden'));
135 document.querySelectorAll('.view:not(.tab-group)').forEach(v => v.classList.add('hidden'));
136
137 if (parentTab) {
138 const groupEl = document.getElementById(`${parentTab}-view`);
139 if (groupEl) groupEl.classList.remove('hidden');
140 } else {
141 // Standalone view (e.g., settings)
142 const standaloneEl = document.getElementById(`${view}-view`);
143 if (standaloneEl) standaloneEl.classList.remove('hidden');
144 }
145
146 // Within the active tab group, show the correct sub-view and activate its pill
147 if (parentTab) {
148 const groupEl = document.getElementById(`${parentTab}-view`);
149 if (groupEl) {
150 // Hide all sub-views in this group
151 groupEl.querySelectorAll('.subview').forEach(sv => sv.classList.add('hidden'));
152 // Show the target sub-view
153 const subviewEl = document.getElementById(`${view}-view`);
154 if (subviewEl) subviewEl.classList.remove('hidden');
155
156 // Update pill active state
157 groupEl.querySelectorAll('.pill-nav .pill').forEach(p => p.classList.remove('active'));
158 const activePill = groupEl.querySelector(`.pill-nav [data-subview="${view}"]`);
159 if (activePill) activePill.classList.add('active');
160 }
161 }
162
163 // Update window title
164 updateWindowTitle(view);
165
166 // Push URL (unless router is handling)
167 if (GoingsOn.router && !GoingsOn.router.suppressPush) {
168 GoingsOn.router.navigate(`/${view}`);
169 }
170
171 // Load data for view
172 loadViewData(view);
173
174 // Update mobile header view title
175 updateMobileViewTitle(view);
176
177 // Update mobile tab bar active state
178 updateMobileTabBar(view);
179 }
180
181 /**
182 * Clean up resources from a view before switching away.
183 * Prevents memory leaks from intervals, listeners, etc.
184 * @param {string} view - The view being navigated away from
185 */
186 function cleanupView(view) {
187 switch (view) {
188 case 'day-plan':
189 if (GoingsOn.dayPlan?.cleanup) {
190 GoingsOn.dayPlan.cleanup();
191 }
192 break;
193 // Add other view cleanup as needed
194 }
195 }
196
197 /**
198 * Update the Tauri window title and document.title for the given view.
199 * @param {string} view - Current view name
200 */
201 async function updateWindowTitle(view) {
202 const viewTitles = {
203 'tasks': 'Tasks',
204 'projects': 'Projects',
205 'events': 'Events',
206 'emails': 'Email',
207 'contacts': 'Contacts',
208 'day-plan': 'Day',
209 'weekly-review': 'Week',
210 'monthly-review': 'Month',
211 'timer': 'Timer',
212 };
213 const title = `GoingsOn - ${viewTitles[view] || 'Home'}`;
214
215 // Update document title (fallback for non-Tauri)
216 document.title = title;
217
218 // Update Tauri window title via API layer
219 try {
220 await GoingsOn.api.window.setTitle(title);
221 } catch {
222 // Silent fallback to document.title
223 }
224 }
225
226 /**
227 * Load data for the specified view by calling its module's load function.
228 * @param {string} view - View name (e.g., 'tasks', 'projects', 'emails')
229 */
230 async function loadViewData(view) {
231 switch (view) {
232 case 'projects': await GoingsOn.projects.load(); break;
233 case 'tasks': await GoingsOn.tasks.load(); break;
234 case 'events': await GoingsOn.events.load(); break;
235 case 'emails': await GoingsOn.emails.load(); break;
236 case 'contacts': await GoingsOn.contacts.load(); break;
237 case 'day-plan': await GoingsOn.dayPlan.load(); break;
238 case 'weekly-review':
239 if (GoingsOn.weeklyReview) await GoingsOn.weeklyReview.load();
240 break;
241 case 'monthly-review':
242 if (GoingsOn.monthlyReview) await GoingsOn.monthlyReview.load();
243 break;
244 case 'timer':
245 if (GoingsOn.timeTracking?.loadTimerView) await GoingsOn.timeTracking.loadTimerView();
246 break;
247 }
248 }
249
250 /**
251 * Get the current active view name from state.
252 * @returns {string} Current view name
253 */
254 function getCurrentView() {
255 return GoingsOn.state.currentView;
256 }
257
258 /**
259 * Set the current view in state without triggering navigation.
260 * @param {string} view - View name to set
261 */
262 function setCurrentView(view) {
263 GoingsOn.state.set('currentView', view);
264 }
265
266 // ============ Mobile View Title ============
267
268 function updateMobileViewTitle(view) {
269 const el = document.getElementById('mobile-view-title');
270 if (!el) return;
271 el.textContent = VIEW_LABELS[view] || '';
272 }
273
274 // ============ Mobile Tab Bar ============
275
276 function updateMobileTabBar(view) {
277 const bar = document.getElementById('mobile-tab-bar');
278 if (!bar) return;
279 const parentTab = TAB_GROUPS[view] || view;
280 bar.querySelectorAll('.mobile-tab[data-view]').forEach(t => {
281 t.classList.toggle('active', t.dataset.view === parentTab);
282 t.setAttribute('aria-selected', t.dataset.view === parentTab ? 'true' : 'false');
283 });
284 }
285
286 /**
287 * Create a new item appropriate for the current view.
288 * Routes to the correct openNew function based on active view.
289 */
290 function newItemForCurrentView() {
291 const view = GoingsOn.state.currentView;
292
293 switch (view) {
294 case 'projects': GoingsOn.projects.openNew?.(); break;
295 case 'tasks': GoingsOn.tasks.openNew?.(); break;
296 case 'events': GoingsOn.events.openNew?.(); break;
297 case 'emails': GoingsOn.emails.openCompose?.(); break;
298 case 'contacts': GoingsOn.contacts.openNew?.(); break;
299 case 'day-plan': GoingsOn.events.openNew?.(); break;
300 default: GoingsOn.tasks.openNew?.(); break;
301 }
302 }
303
304 // ============ Event Wiring ============
305
306 // Sub-view labels per mobile tab. Hardcoded rather than scraped from .pill-nav
307 // so the slide menu can show labels even before the destination tab's DOM has
308 // rendered. First entry in each list reads at the top of the menu; bottom of
309 // the menu (closest to the finger) is the last entry. Each item is either
310 // `{ label, view }` (navigates) or `{ label, action }` (runs a callback).
311 const MOBILE_TAB_PILLS = {
312 work: [
313 { label: 'Projects', view: 'projects' },
314 { label: 'Tasks', view: 'tasks' },
315 ],
316 time: [
317 { label: 'Events', view: 'events' },
318 { label: 'Timer', view: 'timer' },
319 { label: 'Month', view: 'monthly-review' },
320 { label: 'Week', view: 'weekly-review' },
321 { label: 'Day', view: 'day-plan' },
322 ],
323 messages: [
324 { label: 'Drafts', action: () => GoingsOn.emails?.openDrafts?.() },
325 { label: 'Contacts', view: 'contacts' },
326 { label: 'Email', view: 'emails' },
327 ],
328 };
329
330 // Long-press slide-to-select gesture.
331 //
332 // Hold the tab ~HOLD_MS to open a vertical menu anchored above it; slide the
333 // same finger onto an item to highlight it; release on an item to commit,
334 // release with no item highlighted to cancel. A simple tap (release before
335 // HOLD_MS) falls through to the normal click handler.
336 const HOLD_MS = 400;
337 const MOVE_CANCEL_PX = 10;
338
339 function wireTabSlideMenu(tab) {
340 const tabKey = tab.dataset.view;
341 const items = MOBILE_TAB_PILLS[tabKey];
342 if (!items || items.length <= 1) return;
343
344 let holdTimer = null;
345 let pointerId = null;
346 let startX = 0;
347 let startY = 0;
348 let menu = null;
349 let highlighted = null;
350
351 function openMenu() {
352 const rect = tab.getBoundingClientRect();
353 menu = document.createElement('div');
354 menu.className = 'mobile-tab-slide-menu';
355 menu.setAttribute('role', 'menu');
356 items.forEach((item, idx) => {
357 const el = document.createElement('div');
358 el.className = 'mobile-tab-slide-item';
359 el.dataset.idx = String(idx);
360 el.setAttribute('role', 'menuitem');
361 el.textContent = item.label;
362 menu.appendChild(el);
363 });
364 document.body.appendChild(menu);
365 // Position above the tab, horizontally centered on it but clamped to
366 // the viewport edges so the menu doesn't get clipped on narrow screens.
367 const tabCenter = rect.left + rect.width / 2;
368 const menuWidth = menu.offsetWidth;
369 const left = Math.max(8, Math.min(window.innerWidth - menuWidth - 8, tabCenter - menuWidth / 2));
370 const bottom = window.innerHeight - rect.top + 8;
371 menu.style.left = `${left}px`;
372 menu.style.bottom = `${bottom}px`;
373 requestAnimationFrame(() => menu.classList.add('is-open'));
374 }
375
376 function updateHighlight(clientX, clientY) {
377 if (!menu) return;
378 const el = document.elementFromPoint(clientX, clientY);
379 const item = el?.closest?.('.mobile-tab-slide-item');
380 if (item === highlighted) return;
381 if (highlighted) highlighted.classList.remove('is-highlighted');
382 highlighted = (item && menu.contains(item)) ? item : null;
383 if (highlighted) highlighted.classList.add('is-highlighted');
384 }
385
386 function closeMenu() {
387 if (!menu) return;
388 menu.remove();
389 menu = null;
390 highlighted = null;
391 }
392
393 function suppressNextClick() {
394 const block = (e) => {
395 e.preventDefault();
396 e.stopImmediatePropagation();
397 };
398 tab.addEventListener('click', block, { capture: true, once: true });
399 // Click won't always fire after a non-tap touch; clean up if it
400 // doesn't arrive promptly so we don't swallow a real subsequent tap.
401 setTimeout(() => tab.removeEventListener('click', block, { capture: true }), 400);
402 }
403
404 function onPointerDown(e) {
405 if (pointerId !== null) return;
406 if (e.pointerType === 'mouse' && e.button !== 0) return;
407 pointerId = e.pointerId;
408 startX = e.clientX;
409 startY = e.clientY;
410 // Route subsequent move/up events to this element even if the pointer
411 // leaves the tab — saves us from document-level listeners.
412 try { tab.setPointerCapture(pointerId); } catch {}
413 holdTimer = setTimeout(() => {
414 holdTimer = null;
415 openMenu();
416 }, HOLD_MS);
417 }
418
419 function onPointerMove(e) {
420 if (e.pointerId !== pointerId) return;
421 if (holdTimer) {
422 // Pre-open: cancel if pointer drifted (treat as scroll/tap-cancel)
423 if (Math.abs(e.clientX - startX) > MOVE_CANCEL_PX || Math.abs(e.clientY - startY) > MOVE_CANCEL_PX) {
424 clearTimeout(holdTimer);
425 holdTimer = null;
426 cleanup();
427 }
428 return;
429 }
430 if (menu) {
431 e.preventDefault();
432 updateHighlight(e.clientX, e.clientY);
433 }
434 }
435
436 function onPointerUp(e) {
437 if (e.pointerId !== pointerId) return;
438 if (holdTimer) {
439 clearTimeout(holdTimer);
440 holdTimer = null;
441 cleanup();
442 return; // Normal tap — let the click handler run
443 }
444 if (menu) {
445 const commitEl = highlighted;
446 closeMenu();
447 suppressNextClick();
448 if (commitEl) commitItem(commitEl);
449 }
450 cleanup();
451 }
452
453 function commitItem(el) {
454 const idx = parseInt(el.dataset.idx, 10);
455 const item = items[idx];
456 if (!item) return;
457 if (item.view) switchView(item.view);
458 else if (typeof item.action === 'function') item.action();
459 }
460
461 function onPointerCancel(e) {
462 if (e.pointerId !== pointerId) return;
463 if (holdTimer) {
464 clearTimeout(holdTimer);
465 holdTimer = null;
466 }
467 if (menu) {
468 closeMenu();
469 suppressNextClick();
470 }
471 cleanup();
472 }
473
474 function cleanup() {
475 try { if (pointerId !== null) tab.releasePointerCapture(pointerId); } catch {}
476 pointerId = null;
477 }
478
479 tab.addEventListener('pointerdown', onPointerDown);
480 tab.addEventListener('pointermove', onPointerMove);
481 tab.addEventListener('pointerup', onPointerUp);
482 tab.addEventListener('pointercancel', onPointerCancel);
483 }
484
485 function initMobileTabBar() {
486 const bar = document.getElementById('mobile-tab-bar');
487 if (!bar) return;
488
489 // Tab clicks (and long-press slide menu for tabs with sub-views)
490 bar.querySelectorAll('.mobile-tab[data-view]').forEach(tab => {
491 tab.addEventListener('click', () => {
492 switchView(tab.dataset.view);
493 });
494 wireTabSlideMenu(tab);
495 });
496
497 // Create button
498 const createBtn = document.getElementById('mobile-create-btn');
499 if (createBtn) {
500 createBtn.addEventListener('click', () => {
501 newItemForCurrentView();
502 });
503 }
504
505 // Set initial active tab and mobile view title
506 const initialView = GoingsOn.state.currentView || 'tasks';
507 updateMobileTabBar(initialView);
508 updateMobileViewTitle(initialView);
509 }
510
511 // Initialize on DOM ready
512 if (document.readyState === 'loading') {
513 document.addEventListener('DOMContentLoaded', initMobileTabBar);
514 } else {
515 initMobileTabBar();
516 }
517
518 // ============ Populate GoingsOn.navigation Namespace ============
519
520 GoingsOn.navigation = {
521 switchView,
522 updateWindowTitle,
523 loadViewData,
524 getCurrentView,
525 setCurrentView,
526 newItemForCurrentView,
527 };
528
529 // Also update getCurrentView facade
530 GoingsOn.getCurrentView = getCurrentView;
531
532 })();
533