PageSourceSearch

https://smrtbld.ai/js/components/ActivityBadge.js

js smrtbld.ai collected 2026-10-05 15:08:26 UTC 7,943 bytes, 253 lines download raw bytes

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.