import { createPlugin, type PluginDef } from '@fullcalendar/core'; import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'; export interface WindowActivityInPeriod { appName: string; url: string | null; count: number; icon?: string | null; } export interface ActivityPeriod { start: string; end: string; isIdle: boolean; windowActivities?: WindowActivityInPeriod[]; } export interface ActivityStatusPluginOptions { activityPeriods?: ActivityPeriod[]; } // Tooltip state management - single instance per module let tooltipInstance: HTMLElement | null = null; let cleanupAutoUpdate: (() => void) | null = null; /** * Creates and manages a tooltip element for activity status boxes */ function getOrCreateTooltip(): HTMLElement { if (!tooltipInstance) { tooltipInstance = document.createElement('div'); tooltipInstance.className = 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground'; tooltipInstance.style.position = 'fixed'; tooltipInstance.style.pointerEvents = 'none'; tooltipInstance.style.opacity = '0'; tooltipInstance.style.whiteSpace = 'nowrap'; tooltipInstance.style.transform = 'scale(0.95)'; tooltipInstance.style.transition = 'opacity 150ms, transform 150ms'; document.body.appendChild(tooltipInstance); } return tooltipInstance; } /** * Shows tooltip for an activity status box using Floating UI's autoUpdate */ function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) { // Clear previous content tooltip.innerHTML = ''; if (typeof content === 'string') { tooltip.textContent = content; } else { tooltip.appendChild(content); } tooltip.style.opacity = '1'; tooltip.style.transform = 'scale(1)'; // Clean up previous autoUpdate if it exists if (cleanupAutoUpdate) { cleanupAutoUpdate(); } // Use autoUpdate to automatically update position cleanupAutoUpdate = autoUpdate(box, tooltip, () => { computePosition(box, tooltip, { placement: 'right', middleware: [offset(8), flip(), shift({ padding: 5 })], }).then(({ x, y }) => { tooltip.style.left = `${x}px`; tooltip.style.top = `${y}px`; }); }); } /** * Hides the tooltip immediately */ function hideTooltip(tooltip: HTMLElement) { tooltip.style.opacity = '0'; tooltip.style.transform = 'scale(0.95)'; // Clean up autoUpdate when tooltip is hidden if (cleanupAutoUpdate) { cleanupAutoUpdate(); cleanupAutoUpdate = null; } } /** * Formats duration in minutes to human readable format */ function formatDuration(durationMinutes: number): string { const hours = Math.floor(durationMinutes / 60); const minutes = durationMinutes % 60; return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } /** * Creates tooltip content for an activity period */ function createTooltipContent( status: string, durationText: string, windowActivities?: WindowActivityInPeriod[] ): string | HTMLElement { if (!windowActivities || windowActivities.length === 0) { return `${status} (${durationText})`; } const container = document.createElement('div'); container.style.maxWidth = '300px'; // Header with status and duration const header = document.createElement('div'); header.style.fontWeight = '600'; header.style.marginBottom = '8px'; header.textContent = `${status} (${durationText})`; container.appendChild(header); // Window activities list const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0); // Show top 5 activities const topActivities = windowActivities.slice(0, 5); topActivities.forEach((activity) => { const activityDiv = document.createElement('div'); activityDiv.style.marginTop = '4px'; activityDiv.style.fontSize = '11px'; activityDiv.style.opacity = '0.9'; activityDiv.style.display = 'flex'; activityDiv.style.alignItems = 'center'; activityDiv.style.gap = '6px'; // Add icon if available if (activity.icon) { const icon = document.createElement('img'); icon.src = activity.icon; icon.alt = activity.appName; icon.style.width = '16px'; icon.style.height = '16px'; icon.style.borderRadius = '2px'; icon.style.flexShrink = '0'; activityDiv.appendChild(icon); } else { // Placeholder for no icon const placeholder = document.createElement('div'); placeholder.style.width = '16px'; placeholder.style.height = '16px'; placeholder.style.borderRadius = '2px'; placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; placeholder.style.display = 'flex'; placeholder.style.alignItems = 'center'; placeholder.style.justifyContent = 'center'; placeholder.style.fontSize = '8px'; placeholder.style.flexShrink = '0'; placeholder.textContent = activity.appName.charAt(0).toUpperCase(); activityDiv.appendChild(placeholder); } const textSpan = document.createElement('span'); textSpan.style.flex = '1'; textSpan.style.overflow = 'hidden'; textSpan.style.textOverflow = 'ellipsis'; textSpan.style.whiteSpace = 'nowrap'; const percentage = ((activity.count / totalActivities) * 100).toFixed(0); const activityText = activity.url ? `${activity.appName} - ${activity.url}` : activity.appName; textSpan.textContent = `${percentage}% ${activityText}`; activityDiv.appendChild(textSpan); container.appendChild(activityDiv); }); // Show "and X more" if there are more activities if (windowActivities.length > 5) { const moreDiv = document.createElement('div'); moreDiv.style.marginTop = '4px'; moreDiv.style.fontSize = '11px'; moreDiv.style.opacity = '0.7'; moreDiv.style.fontStyle = 'italic'; moreDiv.textContent = `...and ${windowActivities.length - 5} more`; container.appendChild(moreDiv); } return container; } /** * Renders activity status boxes in the calendar time grid */ export function renderActivityStatusBoxes( calendarEl: HTMLElement, activityPeriods: ActivityPeriod[] ) { if (!calendarEl) return; // Clean up existing activity boxes const existingBoxes = calendarEl.querySelectorAll('.activity-status-box'); existingBoxes.forEach((box) => box.remove()); // Remove has-activity-status class from all lanes const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col'); allLanes.forEach((lane) => lane.classList.remove('has-activity-status')); const timeGrid = calendarEl.querySelector('.fc-timegrid-body'); if (!timeGrid) return; const lanes = timeGrid.querySelectorAll('.fc-timegrid-col'); if (lanes.length === 0) return; // Get or reuse the single tooltip instance const tooltip = getOrCreateTooltip(); // Get slot duration from calendar (fallback to 15 minutes) const slotDurationMinutes = getSlotDuration(calendarEl); lanes.forEach((lane: Element) => { // Get the date for this lane from the data attribute const laneEl = lane as HTMLElement; const dateStr = laneEl.getAttribute('data-date'); if (!dateStr) return; const laneDate = new Date(dateStr); const laneDateStart = new Date(laneDate); laneDateStart.setHours(0, 0, 0, 0); const laneDateEnd = new Date(laneDate); laneDateEnd.setHours(23, 59, 59, 999); let hasActivityStatusForThisDay = false; activityPeriods.forEach((period) => { const periodStart = new Date(period.start); const periodEnd = new Date(period.end); // Check if period overlaps with this day if (periodEnd < laneDateStart || periodStart > laneDateEnd) { return; } // Calculate actual start and end times for this day const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart; const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd; // Calculate the position and height of the activity box const { top, height } = calculateBoxPosition( calendarEl, actualStart, actualEnd, slotDurationMinutes ); if (height <= 0) return; hasActivityStatusForThisDay = true; // Calculate duration in minutes const durationMs = actualEnd.getTime() - actualStart.getTime(); const durationMinutes = Math.round(durationMs / 60000); const durationText = formatDuration(durationMinutes); // Add tooltip text based on status const status = period.isIdle ? 'Idling' : 'Active'; // Create and append the activity status box const box = document.createElement('div'); box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`; box.style.top = `${top}px`; box.style.height = `${height}px`; // Store tooltip content generator in data attribute for event delegation const tooltipContent = createTooltipContent( status, durationText, period.windowActivities ); // Add hover event listeners for tooltip box.addEventListener('mouseenter', () => { showTooltip(box, tooltip, tooltipContent); }); box.addEventListener('mouseleave', () => { hideTooltip(tooltip); }); // Position relative to the lane const laneFrame = lane.querySelector('.fc-timegrid-col-frame'); if (laneFrame) { laneFrame.appendChild(box); } }); // Mark this lane as having activity status if any periods were rendered if (hasActivityStatusForThisDay) { laneEl.classList.add('has-activity-status'); } }); } /** * Gets the slot duration from the calendar configuration */ function getSlotDuration(calendarEl: HTMLElement): number { const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot'); if (slotsEl.length < 2) return 15; // Default to 15 minutes // Try to calculate from the time difference between slots const firstSlot = slotsEl[0] as HTMLElement; const secondSlot = slotsEl[1] as HTMLElement; const firstTime = firstSlot.getAttribute('data-time'); const secondTime = secondSlot.getAttribute('data-time'); if (firstTime && secondTime) { const [h1, m1] = firstTime.split(':').map(Number); const [h2, m2] = secondTime.split(':').map(Number); const diff = h2 * 60 + m2 - (h1 * 60 + m1); if (diff > 0) return diff; } // Fallback to 15 minutes return 15; } /** * Calculates the pixel position and height for an activity status box */ function calculateBoxPosition( calendarEl: HTMLElement, startTime: Date, endTime: Date, slotDurationMinutes: number ): { top: number; height: number } { // Get the slot duration and slot height const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot'); if (slotsEl.length === 0) { return { top: 0, height: 0 }; } // Calculate slot height (assuming all slots are equal height) const firstSlot = slotsEl[0] as HTMLElement; const slotHeight = firstSlot.offsetHeight; const pixelsPerMinute = slotHeight / slotDurationMinutes; // Calculate start position (minutes from midnight) const startMinutes = startTime.getHours() * 60 + startTime.getMinutes(); const endMinutes = endTime.getHours() * 60 + endTime.getMinutes(); // Calculate pixel positions const top = startMinutes * pixelsPerMinute; const height = (endMinutes - startMinutes) * pixelsPerMinute; return { top, height }; } /** * Cleanup function to remove tooltip from DOM */ export function cleanupActivityStatusPlugin() { if (tooltipInstance) { tooltipInstance.remove(); tooltipInstance = null; } if (cleanupAutoUpdate) { cleanupAutoUpdate(); cleanupAutoUpdate = null; } } /** * FullCalendar plugin to display idle/active status boxes in the time grid */ const activityStatusPlugin: PluginDef = createPlugin({ name: '@solidtime/activity-status', optionRefiners: { activityPeriods: (rawVal: unknown): ActivityPeriod[] => { if (!Array.isArray(rawVal)) return []; return rawVal as ActivityPeriod[]; }, }, }); export default activityStatusPlugin;