mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-10 09:12:15 +01:00
add support for window activities in the calendar view plugin
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user