From 1e7364fc4bc0a2e7015cb7cb3fb36962dc5b69b9 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 8 Apr 2026 14:43:09 +0200 Subject: [PATCH] show calendar activities more prominently when no time entry exists --- .../ui/src/FullCalendar/CalendarDayColumn.vue | 151 +++++++++++++++++- .../ui/src/FullCalendar/TimeEntryCalendar.vue | 24 ++- .../ui/src/FullCalendar/useActivityBoxes.ts | 16 +- .../ui/src/FullCalendar/useSlotSelection.ts | 2 +- .../ui/src/tooltip/TooltipContent.vue | 2 +- 5 files changed, 180 insertions(+), 15 deletions(-) diff --git a/resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue b/resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue index 27491ea2..7c78c00f 100644 --- a/resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue +++ b/resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue @@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..'; import type { DayEvent, ActivityBox } from './calendarTypes'; import type { WindowActivityInPeriod } from './activityTypes'; -defineProps<{ +const props = defineProps<{ dayStr: string; totalGridHeight: number; hasActivityStatus: boolean; @@ -34,6 +34,8 @@ defineProps<{ getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[]; getActivityPercentage: (count: number, total: number) => string; getActivityText: (activity: WindowActivityInPeriod) => string; + getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null; + isDayView: boolean; // Selection showSelection: boolean; @@ -46,6 +48,16 @@ defineProps<{ selectionEndHeight: number; }>(); +function isUncoveredByEvents(abox: ActivityBox): boolean { + return !props.dayEvents.some((de) => { + const eTop = de.top; + const eBottom = de.top + de.height; + const aTop = abox.top; + const aBottom = abox.top + abox.height; + return eTop < aBottom && eBottom > aTop; + }); +} + const emit = defineEmits<{ (e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void; (e: 'event-keydown-enter', dayEvent: DayEvent): void; @@ -55,6 +67,7 @@ const emit = defineEmits<{ dayEvent: DayEvent, edge: 'start' | 'end' ): void; + (e: 'activity-pointerdown', event: PointerEvent): void; }>(); @@ -63,12 +76,16 @@ const emit = defineEmits<{ class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none" :class="{ 'has-activity-status': hasActivityStatus, + 'activity-expanded': hasActivityStatus && isDayView, }" :data-date="dayStr" :style="{ height: totalGridHeight + 'px' }">
+ :class="{ + 'fc-events-inset': hasActivityStatus && !isDayView, + 'fc-events-inset-expanded': hasActivityStatus && isDayView, + }">
- +
+ :class="[ + abox.isIdle ? 'idle' : 'active', + { + 'activity-status-box-expanded': isDayView, + 'activity-status-box-uncovered': + !isDayView && + !abox.isIdle && + getTopActivity(abox) && + isUncoveredByEvents(abox), + }, + ]" + :style="{ top: abox.top + 'px', height: abox.height + 'px' }" + @pointerdown="emit('activity-pointerdown', $event)"> +
+ +
+ {{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }} +
+ + {{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }} + +
+
- + @@ -269,13 +318,99 @@ const emit = defineEmits<{ background-color: rgba(156, 163, 175, 0.5); } .activity-status-box.active::before { - background-color: rgba(34, 197, 94, 0.3); + background-color: rgba(14, 165, 233, 0.3); } .activity-status-box.active:hover::before { - background-color: rgba(34, 197, 94, 1); + background-color: rgba(14, 165, 233, 1); +} + +/* Uncovered activity boxes in week view — fill column width */ +.activity-status-box-uncovered { + width: calc(100% - 4px); + border-radius: 3px; + overflow: hidden; +} +.activity-status-box-uncovered::before { + left: 0; + right: 0; + width: auto; +} +.activity-status-box-uncovered.active::before { + background-color: rgba(14, 165, 233, 0.12); +} +.activity-status-box-uncovered.active:hover::before { + background-color: rgba(14, 165, 233, 0.25); +} + +/* Expanded activity boxes for day view */ +.activity-status-box-expanded { + width: 200px; + border-radius: 3px; + overflow: hidden; +} +.activity-status-box-expanded::before { + left: 0; + right: 0; + width: auto; +} +.activity-status-box-expanded.idle::before { + background-color: rgba(156, 163, 175, 0.08); +} +.activity-status-box-expanded.idle:hover::before { + background-color: rgba(156, 163, 175, 0.2); +} +.activity-status-box-expanded.active::before { + background-color: rgba(14, 165, 233, 0.12); +} +.activity-status-box-expanded.active:hover::before { + background-color: rgba(14, 165, 233, 0.25); +} + +.activity-status-content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + height: 100%; + overflow: hidden; +} + +.activity-status-icon { + width: 14px; + height: 14px; + border-radius: 2px; + flex-shrink: 0; +} + +.activity-status-icon-fallback { + width: 14px; + height: 14px; + border-radius: 2px; + background-color: rgba(14, 165, 233, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + flex-shrink: 0; + color: rgba(14, 165, 233, 0.8); +} + +.activity-status-label { + font-size: 10px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; } .fc-events-inset { left: 8px; } + +.fc-events-inset-expanded { + left: 204px; +} diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index ef8256ea..d470bd1e 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -163,6 +163,7 @@ const { getActivityBoxActivities, getActivityPercentage, getActivityText, + getTopActivity, } = useActivityBoxes({ activityPeriods: () => props.activityPeriods, viewDays, @@ -280,6 +281,22 @@ watch(showEditTimeEntryModal, (value) => { } }); +/** + * Guards slot pointer-down so that clicks which dismiss an open Reka UI + * layer (context menu, popover, dialog) don't simultaneously start a + * new time-entry selection on the calendar grid. + * + * Because Reka's DismissableLayer registers its document-level + * `pointerdown` listener *without* capture, it fires AFTER the + * calendar grid's own handler. That means when this guard runs, + * `contextMenuOpen` (and modal refs) still reflect the *open* state. + */ +function guardedSlotPointerDown(e: PointerEvent) { + if (contextMenuOpen.value) return; + if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return; + onSlotPointerDown(e); +} + const scrollToCurrentTime = () => { nextTick(() => { if (!scrollerRef.value) return; @@ -490,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
+ @pointerdown="guardedSlotPointerDown($event)">
@@ -611,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number { :get-activity-box-activities="getActivityBoxActivities" :get-activity-percentage="getActivityPercentage" :get-activity-text="getActivityText" + :get-top-activity="getTopActivity" + :is-day-view="activeView === 'timeGridDay'" :show-selection=" isSelecting || showCreateTimeEntryModal " @@ -629,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number { :selection-height="selectionHeight" :selection-end-top="selectionEndTop" :selection-end-height="selectionEndHeight" + @activity-pointerdown="guardedSlotPointerDown" @event-pointerdown=" (e, dayEvent) => onEventPointerDown(e, dayEvent.event, dayEvent) diff --git a/resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts b/resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts index 27db9d66..13af9d79 100644 --- a/resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts +++ b/resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts @@ -21,10 +21,10 @@ export function useActivityBoxes(params: { function getActivityBoxLabel(box: ActivityBox): string { const periodStart = getLocalizedDayJs(box.period.start); const periodEnd = getLocalizedDayJs(box.period.end); - const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true)); - const durationText = formatActivityDuration(durationMinutes); + const startText = periodStart.format('HH:mm'); + const endText = periodEnd.format('HH:mm'); const status = box.isIdle ? 'Idling' : 'Active'; - return `${status} (${durationText})`; + return `${status} (${startText} - ${endText})`; } function getActivityBoxActivities(box: ActivityBox) { @@ -40,6 +40,15 @@ export function useActivityBoxes(params: { return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName; } + function getTopActivity(box: ActivityBox): WindowActivityInPeriod | null { + const activities = box.period.windowActivities; + if (!activities || activities.length === 0) return null; + return activities.reduce( + (top, a) => (a.count > top.count ? a : top), + activities[0]! + ); + } + const activityBoxes = computed(() => { const periods = params.activityPeriods(); if (!periods || periods.length === 0) return []; @@ -99,5 +108,6 @@ export function useActivityBoxes(params: { getActivityBoxActivities, getActivityPercentage, getActivityText, + getTopActivity, }; } diff --git a/resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts b/resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts index 9b462c98..f5c17ebc 100644 --- a/resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts +++ b/resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts @@ -29,7 +29,7 @@ export function useSlotSelection(params: { function onSlotPointerDown(e: PointerEvent) { if (e.button !== 0) return; const target = e.target as HTMLElement; - if (target.closest('.fc-event') || target.closest('.activity-status-box')) return; + if (target.closest('.fc-event')) return; const dateStr = params.getDayFromClientX(e.clientX); if (!dateStr) return; diff --git a/resources/js/packages/ui/src/tooltip/TooltipContent.vue b/resources/js/packages/ui/src/tooltip/TooltipContent.vue index 604a5e2c..ffcfe7f5 100644 --- a/resources/js/packages/ui/src/tooltip/TooltipContent.vue +++ b/resources/js/packages/ui/src/tooltip/TooltipContent.vue @@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits); v-bind="{ ...forwarded, ...$attrs }" :class=" cn( - 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 overflow-hidden rounded-md shadow-dropdown border border-border bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class ) ">