From 3acf9b8b07406b32fb4ba472535f6b68100dc334 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 3 Dec 2025 18:08:00 +0100 Subject: [PATCH] add support for window activities in the calendar view plugin --- .../ui/src/FullCalendar/TimeEntryCalendar.vue | 31 +- .../ui/src/FullCalendar/idleStatusPlugin.ts | 306 +++++++++++++----- 2 files changed, 251 insertions(+), 86 deletions(-) diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 19aa4ab4..4a54434a 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -712,28 +712,41 @@ onUnmounted(() => { /* Activity status plugin styles */ .fullcalendar :deep(.activity-status-box) { + position: absolute; + width: 10px; + left: 0px; + z-index: 10; + cursor: default; +} + +.fullcalendar :deep(.activity-status-box::before) { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 5px; transition: opacity 0.2s ease; } -.fullcalendar :deep(.activity-status-box.idle) { - background-color: rgba(156, 163, 175, 0.1) !important; +.fullcalendar :deep(.activity-status-box.idle::before) { + background-color: rgba(156, 163, 175, 0.1); } -.fullcalendar :deep(.activity-status-box.idle):hover { - background-color: rgba(156, 163, 175, 0.5) !important; +.fullcalendar :deep(.activity-status-box.idle):hover::before { + background-color: rgba(156, 163, 175, 0.5); } -.fullcalendar :deep(.activity-status-box.active) { - background-color: rgba(34, 197, 94, 0.3) !important; +.fullcalendar :deep(.activity-status-box.active::before) { + background-color: rgba(34, 197, 94, 0.3); } -.fullcalendar :deep(.activity-status-box.active):hover { - background-color: rgba(34, 197, 94, 1) !important; +.fullcalendar :deep(.activity-status-box.active):hover::before { + background-color: rgba(34, 197, 94, 1); } /* Add left margin to events only on days with activity status data */ .fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) { - margin-left: 15px !important; + margin-left: 8px !important; } .fullcalendar :deep(.fc-timegrid-event) { diff --git a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts index c525e6ca..d33721af 100644 --- a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts +++ b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts @@ -1,42 +1,70 @@ import { createPlugin, type PluginDef } from '@fullcalendar/core'; -import { computePosition, flip, shift, offset } from '@floating-ui/dom'; +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 createTooltip(): HTMLElement { - const tooltip = document.createElement('div'); - tooltip.className = - 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground'; - tooltip.style.position = 'fixed'; - tooltip.style.pointerEvents = 'none'; - tooltip.style.opacity = '0'; - tooltip.style.whiteSpace = 'nowrap'; - tooltip.style.transform = 'scale(0.95)'; - tooltip.style.transition = 'opacity 150ms, transform 150ms'; - document.body.appendChild(tooltip); - return tooltip; +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 + * Shows tooltip for an activity status box using Floating UI's autoUpdate */ -function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) { - tooltip.textContent = text; +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)'; - const updatePosition = () => { + // 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 })], @@ -44,17 +72,124 @@ function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) { tooltip.style.left = `${x}px`; tooltip.style.top = `${y}px`; }); - }; - - updatePosition(); + }); } /** - * Hides the tooltip + * 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; } /** @@ -66,49 +201,32 @@ export function renderActivityStatusBoxes( ) { if (!calendarEl) return; - // Clean up existing activity boxes and markers first + // Clean up existing activity boxes const existingBoxes = calendarEl.querySelectorAll('.activity-status-box'); existingBoxes.forEach((box) => box.remove()); - // Clean up existing tooltips - const existingTooltips = document.querySelectorAll('.activity-status-tooltip'); - existingTooltips.forEach((tooltip) => tooltip.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) { - console.log('No timegrid found'); - return; - } + if (!timeGrid) return; const lanes = timeGrid.querySelectorAll('.fc-timegrid-col'); - if (lanes.length === 0) { - console.log('No lanes found'); - return; - } + if (lanes.length === 0) return; - console.log( - 'Rendering activity status boxes, lanes:', - lanes.length, - 'periods:', - activityPeriods.length - ); + // Get or reuse the single tooltip instance + const tooltip = getOrCreateTooltip(); - // Create a single tooltip instance to be reused - const tooltip = createTooltip(); + // Get slot duration from calendar (fallback to 15 minutes) + const slotDurationMinutes = getSlotDuration(calendarEl); - lanes.forEach((lane: Element, dayIndex: number) => { + 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) { - console.log('No date attribute found for lane', dayIndex); - return; - } + if (!dateStr) return; const laneDate = new Date(dateStr); const laneDateStart = new Date(laneDate); @@ -127,47 +245,46 @@ export function renderActivityStatusBoxes( return; } - // Calculate the position and height of the idle box + // 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, - periodStart > laneDateStart ? periodStart : laneDateStart, - periodEnd < laneDateEnd ? periodEnd : laneDateEnd + actualStart, + actualEnd, + slotDurationMinutes ); if (height <= 0) return; hasActivityStatusForThisDay = true; - // Create and append the activity status box - const box = document.createElement('div'); - box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`; - box.style.position = 'absolute'; - box.style.top = `${top}px`; - box.style.height = `${height}px`; - box.style.width = '8px'; - box.style.left = '4px'; - box.style.right = '4px'; - box.style.zIndex = '10'; - box.style.cursor = 'default'; - // Calculate duration in minutes - const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart; - const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd; const durationMs = actualEnd.getTime() - actualStart.getTime(); const durationMinutes = Math.round(durationMs / 60000); - - // Format duration - const hours = Math.floor(durationMinutes / 60); - const minutes = durationMinutes % 60; - const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + const durationText = formatDuration(durationMinutes); // Add tooltip text based on status const status = period.isIdle ? 'Idling' : 'Active'; - const tooltipText = `${status} (${durationText})`; + + // 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, tooltipText); + showTooltip(box, tooltip, tooltipContent); }); box.addEventListener('mouseleave', () => { @@ -178,8 +295,6 @@ export function renderActivityStatusBoxes( const laneFrame = lane.querySelector('.fc-timegrid-col-frame'); if (laneFrame) { laneFrame.appendChild(box); - } else { - console.log('No lane frame found'); } }); @@ -190,18 +305,43 @@ export function renderActivityStatusBoxes( }); } +/** + * 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 + 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) { - console.log('No slots found'); return { top: 0, height: 0 }; } @@ -209,8 +349,6 @@ function calculateBoxPosition( const firstSlot = slotsEl[0] as HTMLElement; const slotHeight = firstSlot.offsetHeight; - // Each slot is 15 minutes by default (configured in TimeEntryCalendar) - const slotDurationMinutes = 15; const pixelsPerMinute = slotHeight / slotDurationMinutes; // Calculate start position (minutes from midnight) @@ -224,6 +362,20 @@ function calculateBoxPosition( 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 */