From 717fd35d760ff6c8555f0f25b2981295e5962b5c Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 18 Nov 2025 13:58:30 +0100 Subject: [PATCH] add tooltips to idlestatus indicators --- .../ui/src/FullCalendar/TimeEntryCalendar.vue | 2 +- .../ui/src/FullCalendar/idleStatusPlugin.ts | 80 ++++++++++++++++++- .../src/TimeEntry/TimeEntryAggregateRow.vue | 2 +- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 41da898d..38c76e91 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -521,7 +521,7 @@ onActivated(() => { } .fullcalendar :deep(.fc-event) { - border-radius: var(--radius); + border-radius: calc(var(--radius) - 4px); padding: 0; font-size: 0.75rem; cursor: pointer; diff --git a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts index a4315dbc..c71e39d4 100644 --- a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts +++ b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts @@ -1,4 +1,5 @@ import { createPlugin, PluginDef } from '@fullcalendar/core'; +import { computePosition, flip, shift, offset } from '@floating-ui/dom'; export interface ActivityPeriod { start: string; @@ -17,6 +18,52 @@ declare module '@fullcalendar/core' { } } +/** + * 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; +} + +/** + * Shows tooltip for an activity status box + */ +function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) { + tooltip.textContent = text; + tooltip.style.opacity = '1'; + tooltip.style.transform = 'scale(1)'; + + const updatePosition = () => { + 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`; + }); + }; + + updatePosition(); +} + +/** + * Hides the tooltip + */ +function hideTooltip(tooltip: HTMLElement) { + tooltip.style.opacity = '0'; + tooltip.style.transform = 'scale(0.95)'; +} + /** * Renders activity status boxes in the calendar time grid */ @@ -30,6 +77,10 @@ export function renderActivityStatusBoxes( 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')); @@ -53,6 +104,9 @@ export function renderActivityStatusBoxes( activityPeriods.length ); + // Create a single tooltip instance to be reused + const tooltip = createTooltip(); + // Get the calendar's current view to determine dates const dateHeaders = calendarEl.querySelectorAll('.fc-col-header-cell'); @@ -115,7 +169,31 @@ export function renderActivityStatusBoxes( box.style.left = '4px'; box.style.right = '4px'; box.style.zIndex = '10'; - box.style.borderRadius = '4px'; + 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`; + + // Add tooltip text based on status + const status = period.isIdle ? 'Idling' : 'Active'; + const tooltipText = `${status} (${durationText})`; + + // Add hover event listeners for tooltip + box.addEventListener('mouseenter', () => { + showTooltip(box, tooltip, tooltipText); + }); + + box.addEventListener('mouseleave', () => { + hideTooltip(tooltip); + }); // Position relative to the lane const laneFrame = lane.querySelector('.fc-timegrid-col-frame'); diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue index 3469ccbd..f875f855 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue @@ -102,7 +102,7 @@ function onSelectChange(checked: boolean) { ) " @update:checked="onSelectChange" /> -
+
{{ timeEntry?.timeEntries?.length }}