/** * GoingsOn - Navigation Module * View switching, window title management, tab navigation, mobile tab bar */ (function() { 'use strict'; // ============ Tab Group Mappings ============ const TAB_GROUPS = { 'tasks': 'work', 'projects': 'work', 'project-dashboard': 'work', 'task-overview': 'work', 'day-plan': 'time', 'weekly-review': 'time', 'monthly-review': 'time', 'timer': 'time', 'events': 'time', 'emails': 'messages', 'contacts': 'messages', 'contact-dashboard': 'messages', }; const TAB_DEFAULTS = { 'work': 'tasks', 'time': 'day-plan', 'messages': 'emails', }; const VIEW_LABELS = { 'work': 'Work', 'time': 'Time', 'messages': 'Messages', 'tasks': 'Tasks', 'projects': 'Projects', 'emails': 'Email', 'contacts': 'Contacts', 'day-plan': 'Day', 'weekly-review': 'Week', 'monthly-review': 'Month', 'timer': 'Timer', 'contact-dashboard': 'Contact', }; // ============ View Navigation ============ // Tab click handlers document.querySelectorAll('.tab-navigation .tab').forEach(tab => { tab.addEventListener('click', (e) => { e.preventDefault(); const view = tab.dataset.view; switchView(view); }); }); // Pill click handlers document.querySelectorAll('.pill-nav .pill').forEach(pill => { pill.addEventListener('click', () => { switchView(pill.dataset.subview); }); }); // Global keyboard handler for interactive elements with role="button" or role="row" // This enables keyboard activation (Enter/Space) for clickable divs, rows, etc. document.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { const target = e.target; const role = target.getAttribute('role'); if (role === 'button' || role === 'row' || role === 'listitem') { // Don't interfere with actual buttons or form inputs if (target.tagName !== 'BUTTON' && target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') { e.preventDefault(); target.click(); } } } }); /** * Switch the active view and load its data. * Handles tab/pill state, URL routing, and mobile tab bar. * @param {string} view - View name or tab alias (e.g., 'tasks', 'work', 'emails') */ function switchView(view) { // If view is a tab name, resolve to the last-used sub-view for that tab, // falling back to the hardcoded default on first visit of the session. if (TAB_DEFAULTS[view]) { const remembered = GoingsOn.state.lastSubviewByTab?.[view]; view = remembered || TAB_DEFAULTS[view]; } // Cleanup previous view resources before switching cleanupView(GoingsOn.state.currentView); // Track previous view for back navigation (e.g., Settings back button) if (GoingsOn.state.currentView && GoingsOn.state.currentView !== view) { GoingsOn.state.previousView = GoingsOn.state.currentView; } GoingsOn.state.set('currentView', view); // Track view switch counts const c = GoingsOn.state.viewSwitchCounts || {}; c[view] = (c[view] || 0) + 1; GoingsOn.state.viewSwitchCounts = c; // Determine parent tab const parentTab = TAB_GROUPS[view]; // Remember this as the last-used sub-view for its parent tab so a // subsequent tap on the mobile tab returns here instead of the default. if (parentTab) { const m = GoingsOn.state.lastSubviewByTab || {}; m[parentTab] = view; GoingsOn.state.lastSubviewByTab = m; } // Update tab active state and aria-selected document.querySelectorAll('.tab-navigation .tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); if (parentTab) { const activeTab = document.querySelector(`.tab-navigation [data-view="${parentTab}"]`); if (activeTab) { activeTab.classList.add('active'); activeTab.setAttribute('aria-selected', 'true'); } } // Hide all views (tab groups and standalone views like settings) document.querySelectorAll('.view.tab-group').forEach(v => v.classList.add('hidden')); document.querySelectorAll('.view:not(.tab-group)').forEach(v => v.classList.add('hidden')); if (parentTab) { const groupEl = document.getElementById(`${parentTab}-view`); if (groupEl) groupEl.classList.remove('hidden'); } else { // Standalone view (e.g., settings) const standaloneEl = document.getElementById(`${view}-view`); if (standaloneEl) standaloneEl.classList.remove('hidden'); } // Within the active tab group, show the correct sub-view and activate its pill if (parentTab) { const groupEl = document.getElementById(`${parentTab}-view`); if (groupEl) { // Hide all sub-views in this group groupEl.querySelectorAll('.subview').forEach(sv => sv.classList.add('hidden')); // Show the target sub-view const subviewEl = document.getElementById(`${view}-view`); if (subviewEl) subviewEl.classList.remove('hidden'); // Update pill active state groupEl.querySelectorAll('.pill-nav .pill').forEach(p => p.classList.remove('active')); const activePill = groupEl.querySelector(`.pill-nav [data-subview="${view}"]`); if (activePill) activePill.classList.add('active'); } } // Update window title updateWindowTitle(view); // Push URL (unless router is handling) if (GoingsOn.router && !GoingsOn.router.suppressPush) { GoingsOn.router.navigate(`/${view}`); } // Load data for view loadViewData(view); // Update mobile header view title updateMobileViewTitle(view); // Update mobile tab bar active state updateMobileTabBar(view); } /** * Clean up resources from a view before switching away. * Prevents memory leaks from intervals, listeners, etc. * @param {string} view - The view being navigated away from */ function cleanupView(view) { switch (view) { case 'day-plan': if (GoingsOn.dayPlan?.cleanup) { GoingsOn.dayPlan.cleanup(); } break; // Add other view cleanup as needed } } /** * Update the Tauri window title and document.title for the given view. * @param {string} view - Current view name */ async function updateWindowTitle(view) { const viewTitles = { 'tasks': 'Tasks', 'projects': 'Projects', 'events': 'Events', 'emails': 'Email', 'contacts': 'Contacts', 'day-plan': 'Day', 'weekly-review': 'Week', 'monthly-review': 'Month', 'timer': 'Timer', }; const title = `GoingsOn - ${viewTitles[view] || 'Home'}`; // Update document title (fallback for non-Tauri) document.title = title; // Update Tauri window title via API layer try { await GoingsOn.api.window.setTitle(title); } catch { // Silent fallback to document.title } } /** * Load data for the specified view by calling its module's load function. * @param {string} view - View name (e.g., 'tasks', 'projects', 'emails') */ async function loadViewData(view) { switch (view) { case 'projects': await GoingsOn.projects.load(); break; case 'tasks': await GoingsOn.tasks.load(); break; case 'events': await GoingsOn.events.load(); break; case 'emails': await GoingsOn.emails.load(); break; case 'contacts': await GoingsOn.contacts.load(); break; case 'day-plan': await GoingsOn.dayPlan.load(); break; case 'weekly-review': if (GoingsOn.weeklyReview) await GoingsOn.weeklyReview.load(); break; case 'monthly-review': if (GoingsOn.monthlyReview) await GoingsOn.monthlyReview.load(); break; case 'timer': if (GoingsOn.timeTracking?.loadTimerView) await GoingsOn.timeTracking.loadTimerView(); break; } } /** * Get the current active view name from state. * @returns {string} Current view name */ function getCurrentView() { return GoingsOn.state.currentView; } /** * Set the current view in state without triggering navigation. * @param {string} view - View name to set */ function setCurrentView(view) { GoingsOn.state.set('currentView', view); } // ============ Mobile View Title ============ function updateMobileViewTitle(view) { const el = document.getElementById('mobile-view-title'); if (!el) return; el.textContent = VIEW_LABELS[view] || ''; } // ============ Mobile Tab Bar ============ function updateMobileTabBar(view) { const bar = document.getElementById('mobile-tab-bar'); if (!bar) return; const parentTab = TAB_GROUPS[view] || view; bar.querySelectorAll('.mobile-tab[data-view]').forEach(t => { t.classList.toggle('active', t.dataset.view === parentTab); t.setAttribute('aria-selected', t.dataset.view === parentTab ? 'true' : 'false'); }); } /** * Create a new item appropriate for the current view. * Routes to the correct openNew function based on active view. */ function newItemForCurrentView() { const view = GoingsOn.state.currentView; switch (view) { 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; case 'day-plan': GoingsOn.events.openNew?.(); break; default: GoingsOn.tasks.openNew?.(); break; } } // ============ Event Wiring ============ // Sub-view labels per mobile tab. Hardcoded rather than scraped from .pill-nav // so the slide menu can show labels even before the destination tab's DOM has // rendered. First entry in each list reads at the top of the menu; bottom of // the menu (closest to the finger) is the last entry. Each item is either // `{ label, view }` (navigates) or `{ label, action }` (runs a callback). const MOBILE_TAB_PILLS = { work: [ { label: 'Projects', view: 'projects' }, { label: 'Tasks', view: 'tasks' }, ], time: [ { label: 'Events', view: 'events' }, { label: 'Timer', view: 'timer' }, { label: 'Month', view: 'monthly-review' }, { label: 'Week', view: 'weekly-review' }, { label: 'Day', view: 'day-plan' }, ], messages: [ { label: 'Drafts', action: () => GoingsOn.emails?.openDrafts?.() }, { label: 'Contacts', view: 'contacts' }, { label: 'Email', view: 'emails' }, ], }; // Long-press slide-to-select gesture. // // Hold the tab ~HOLD_MS to open a vertical menu anchored above it; slide the // same finger onto an item to highlight it; release on an item to commit, // release with no item highlighted to cancel. A simple tap (release before // HOLD_MS) falls through to the normal click handler. const HOLD_MS = 400; const MOVE_CANCEL_PX = 10; function wireTabSlideMenu(tab) { const tabKey = tab.dataset.view; const items = MOBILE_TAB_PILLS[tabKey]; if (!items || items.length <= 1) return; let holdTimer = null; let pointerId = null; let startX = 0; let startY = 0; let menu = null; let highlighted = null; function openMenu() { const rect = tab.getBoundingClientRect(); menu = document.createElement('div'); menu.className = 'mobile-tab-slide-menu'; menu.setAttribute('role', 'menu'); items.forEach((item, idx) => { const el = document.createElement('div'); el.className = 'mobile-tab-slide-item'; el.dataset.idx = String(idx); el.setAttribute('role', 'menuitem'); el.textContent = item.label; menu.appendChild(el); }); document.body.appendChild(menu); // Position above the tab, horizontally centered on it but clamped to // the viewport edges so the menu doesn't get clipped on narrow screens. const tabCenter = rect.left + rect.width / 2; const menuWidth = menu.offsetWidth; const left = Math.max(8, Math.min(window.innerWidth - menuWidth - 8, tabCenter - menuWidth / 2)); const bottom = window.innerHeight - rect.top + 8; menu.style.left = `${left}px`; menu.style.bottom = `${bottom}px`; requestAnimationFrame(() => menu.classList.add('is-open')); } function updateHighlight(clientX, clientY) { if (!menu) return; const el = document.elementFromPoint(clientX, clientY); const item = el?.closest?.('.mobile-tab-slide-item'); if (item === highlighted) return; if (highlighted) highlighted.classList.remove('is-highlighted'); highlighted = (item && menu.contains(item)) ? item : null; if (highlighted) highlighted.classList.add('is-highlighted'); } function closeMenu() { if (!menu) return; menu.remove(); menu = null; highlighted = null; } function suppressNextClick() { const block = (e) => { e.preventDefault(); e.stopImmediatePropagation(); }; tab.addEventListener('click', block, { capture: true, once: true }); // Click won't always fire after a non-tap touch; clean up if it // doesn't arrive promptly so we don't swallow a real subsequent tap. setTimeout(() => tab.removeEventListener('click', block, { capture: true }), 400); } function onPointerDown(e) { if (pointerId !== null) return; if (e.pointerType === 'mouse' && e.button !== 0) return; pointerId = e.pointerId; startX = e.clientX; startY = e.clientY; // Route subsequent move/up events to this element even if the pointer // leaves the tab — saves us from document-level listeners. try { tab.setPointerCapture(pointerId); } catch {} holdTimer = setTimeout(() => { holdTimer = null; openMenu(); }, HOLD_MS); } function onPointerMove(e) { if (e.pointerId !== pointerId) return; if (holdTimer) { // Pre-open: cancel if pointer drifted (treat as scroll/tap-cancel) if (Math.abs(e.clientX - startX) > MOVE_CANCEL_PX || Math.abs(e.clientY - startY) > MOVE_CANCEL_PX) { clearTimeout(holdTimer); holdTimer = null; cleanup(); } return; } if (menu) { e.preventDefault(); updateHighlight(e.clientX, e.clientY); } } function onPointerUp(e) { if (e.pointerId !== pointerId) return; if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; cleanup(); return; // Normal tap — let the click handler run } if (menu) { const commitEl = highlighted; closeMenu(); suppressNextClick(); if (commitEl) commitItem(commitEl); } cleanup(); } function commitItem(el) { const idx = parseInt(el.dataset.idx, 10); const item = items[idx]; if (!item) return; if (item.view) switchView(item.view); else if (typeof item.action === 'function') item.action(); } function onPointerCancel(e) { if (e.pointerId !== pointerId) return; if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } if (menu) { closeMenu(); suppressNextClick(); } cleanup(); } function cleanup() { try { if (pointerId !== null) tab.releasePointerCapture(pointerId); } catch {} pointerId = null; } tab.addEventListener('pointerdown', onPointerDown); tab.addEventListener('pointermove', onPointerMove); tab.addEventListener('pointerup', onPointerUp); tab.addEventListener('pointercancel', onPointerCancel); } function initMobileTabBar() { const bar = document.getElementById('mobile-tab-bar'); if (!bar) return; // Tab clicks (and long-press slide menu for tabs with sub-views) bar.querySelectorAll('.mobile-tab[data-view]').forEach(tab => { tab.addEventListener('click', () => { switchView(tab.dataset.view); }); wireTabSlideMenu(tab); }); // Create button const createBtn = document.getElementById('mobile-create-btn'); if (createBtn) { createBtn.addEventListener('click', () => { newItemForCurrentView(); }); } // Set initial active tab and mobile view title const initialView = GoingsOn.state.currentView || 'tasks'; updateMobileTabBar(initialView); updateMobileViewTitle(initialView); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initMobileTabBar); } else { initMobileTabBar(); } // ============ Populate GoingsOn.navigation Namespace ============ GoingsOn.navigation = { switchView, updateWindowTitle, loadViewData, getCurrentView, setCurrentView, newItemForCurrentView, }; // Also update getCurrentView facade GoingsOn.getCurrentView = getCurrentView; })();