1/** 2 * ActivityBadge â Header badge + daily summary popover for the activity tracker. 3 * 4 * Subscribes to activity:tick events and updates the badge DOM. 5 * Renders a popover with today's time breakdown on click. 6 */ 7 8import { pubsub } from '../utils/pubsub.js'; 9import { getSubModuleName } from '../services/route-context.js'; 10import { loadTemplates, createTemplateLoader } from '../utils/templates.js'; 11import activityTracker from '../services/activity-tracker.js'; 12import { formatDurationFromSeconds } from '../utils/time-utils.js'; 13 14// ============================================================================= 15// STATE 16// ============================================================================= 17 18let badgeEl = null; 19let dotEl = null; 20let labelEl = null; 21let timeEl = null; 22let popoverEl = null; 23let isPopoverOpen = false; 24let tickUnsub = null; 25let enabledUnsub = null; 26let outsideClickHandler = null; 27const ensureActivityBadgeTemplates = createTemplateLoader('components/activity-badge-templates'); 28 29// ============================================================================= 30// INITIALIZATION 31// ============================================================================= 32 33export async function initActivityBadge() { 34 badgeEl = document.getElementById('btnActivityBadge'); 35 dotEl = document.getElementById('activity-dot'); 36 labelEl = document.getElementById('activity-label'); 37 timeEl = document.getElementById('activity-time'); 38 popoverEl = document.getElementById('activity-popover'); 39 40 if (!badgeEl) return; 41 42 // Hide badge if tracking is disabled 43 if (!activityTracker.isEnabled()) { 44 badgeEl.classList.add('hidden'); 45 badgeEl.classList.remove('flex'); 46 return; 47 } 48 49 // Load JsRender templates for the popover 50 await ensureActivityBadgeTemplates(); 51 52 // Show the badge 53 badgeEl.classList.remove('hidden'); 54 badgeEl.classList.add('flex'); 55 56 // Badge click â toggle popover 57 badgeEl.addEventListener('click', togglePopover); 58 59 // Subscribe to tick events and enabled toggle 60 tickUnsub = pubsub.subscribe('activity:tick', updateBadge); 61 enabledUnsub = pubsub.subscribe('activity:enabledChanged', onEnabledChanged); 62 63 // Initial render 64 const state = activityTracker.getState(); 65 if (state.isTracking) { 66 updateBadge({ 67 session: state.session, 68 isIdle: state.isIdle, 69 isTabVisible: state.isTabVisible, 70 }); 71 } else { 72 renderIdle(); 73 } 74} 75 76export function destroyActivityBadge() { 77 if (tickUnsub) tickUnsub(); 78 if (enabledUnsub) enabledUnsub(); 79 tickUnsub = null; 80 enabledUnsub = null; 81 82 if (badgeEl) { 83 badgeEl.removeEventListener('click', togglePopover); 84 } 85 closePopover(); 86 87 badgeEl = null; 88 dotEl = null; 89 labelEl = null; 90 timeEl = null; 91 popoverEl = null; 92} 93 94// ============================================================================= 95// BADGE RENDERING 96// ============================================================================= 97 98function updateBadge(data) { 99 if (!badgeEl || !dotEl || !labelEl || !timeEl) return; 100 101 const { session, isIdle, isTabVisible } = data; 102 103 if (!session) { 104 renderIdle(); 105 return; 106 } 107 108 const displayName = session.subModule 109 ? getSubModuleName(session.subModule) 110 : session.moduleName || session.module; 111 112 const timeStr = formatDurationFromSeconds(session.activeSeconds); 113 114 labelEl.textContent = displayName; 115 timeEl.textContent = timeStr; 116 117 if (isIdle || !isTabVisible) { 118 // Paused state 119 dotEl.className = 'w-2 h-2 rounded-full bg-sb-warning shrink-0'; 120 badgeEl.title = `Paused â ${displayName} (${timeStr})`; 121 } else { 122 // Active state 123 dotEl.className = 'w-2 h-2 rounded-full bg-sb-success shrink-0 animate-pulse'; 124 badgeEl.title = `Tracking â ${displayName} (${timeStr})`; 125 } 126} 127 128function renderIdle() { 129 if (!dotEl || !labelEl || !timeEl) return; 130 dotEl.className = 'w-2 h-2 rounded-full bg-theme-tertiary shrink-0'; 131 labelEl.textContent = 'Idle'; 132 timeEl.textContent = ''; 133 if (badgeEl) badgeEl.title = 'Activity tracking â idle'; 134} 135 136function onEnabledChanged(enabled) { 137 if (!badgeEl) return; 138 if (enabled) { 139 badgeEl.classList.remove('hidden'); 140 badgeEl.classList.add('flex'); 141 renderIdle(); 142 } else { 143 badgeEl.classList.add('hidden'); 144 badgeEl.classList.remove('flex'); 145 closePopover(); 146 } 147} 148 149// ============================================================================= 150// POPOVER 151// ============================================================================= 152 153function togglePopover(e) { 154 e.stopPropagation(); 155 if (isPopoverOpen) { 156 closePopover(); 157 } else { 158 openPopover(); 159 } 160} 161 162function openPopover() {
163 if (!popoverEl) return; 164 165 renderPopoverContent(); 166 popoverEl.classList.remove('hidden'); 167 isPopoverOpen = true; 168 169 // Close on outside click (delayed to avoid immediate close) 170 requestAnimationFrame(() => { 171 outsideClickHandler = (e) => { 172 if (!popoverEl.contains(e.target) && !badgeEl.contains(e.target)) { 173 closePopover(); 174 } 175 }; 176 document.addEventListener('click', outsideClickHandler); 177 }); 178} 179 180function closePopover() { 181 if (popoverEl) { 182 popoverEl.classList.add('hidden'); 183 } 184 isPopoverOpen = false; 185 186 if (outsideClickHandler) { 187 document.removeEventListener('click', outsideClickHandler); 188 outsideClickHandler = null; 189 } 190} 191 192function renderPopoverContent() { 193 if (!popoverEl) return; 194 195 const summary = activityTracker.getDailySummary(); 196 const today = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 197 198 if (summary.projects.length === 0) { 199 const tmpl = $.templates('#tpl-activity-popover-empty'); 200 popoverEl.innerHTML = tmpl.render({ today }); 201 lucide.createIcons({ root: popoverEl }); 202 return; 203 } 204 205 const data = { 206 today, 207 totalDuration: formatDurationFromSeconds(summary.totalActiveSeconds), 208 projects: summary.projects.map(proj => ({ 209 displayName: proj.projectName || (proj.projectId ? `Project #${proj.projectId}` : 'General'), 210 duration: formatDurationFromSeconds(proj.activeSeconds), 211 modules: proj.modules.map(mod => ({ 212 displayName: MODULE_DISPLAY_NAMES[mod.module] || mod.moduleName || mod.module, 213 duration: formatDurationFromSeconds(mod.activeSeconds), 214 subModules: mod.subModules.map(sub => ({ 215 displayName: getSubModuleName(sub.subModule), 216 duration: formatDurationFromSeconds(sub.activeSeconds), 217 })), 218 })), 219 })), 220 }; 221 222 const tmpl = $.templates('#tpl-activity-popover'); 223 popoverEl.innerHTML = tmpl.render(data); 224 lucide.createIcons({ root: popoverEl }); 225} 226 227// ============================================================================= 228// HELPERS 229// ============================================================================= 230 231const MODULE_DISPLAY_NAMES = { 232 'contract-admin': 'Contract Admin', 233 'design-dev': 'Design & Dev', 234 'permitting': 'Permitting', 235 'bids-tendering': 'Bids & Tendering', 236 'deficiency-list': 'Deficiency List', 237 'post-construction': 'Post-Construction', 238 'proposals': 'Proposals', 239 'projects': 'Projects', 240 'project-pursuit': 'Project Pursuit', 241 'messages': 'Messages', 242 'team': 'Team', 243 'suppliers': 'Suppliers', 244 'contacts': 'Contacts', 245 'calendar': 'Calendar', 246 'settings': 'Settings', 247 'dashboard': 'Dashboard', 248}; 249 250export default { 251 initActivityBadge, 252 destroyActivityBadge, 253};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.