show calendar activities more prominently when no time entry exists

This commit is contained in:
Gregor Vostrak
2026-04-08 14:43:09 +02:00
parent 8cbc9838c9
commit 1e7364fc4b
5 changed files with 180 additions and 15 deletions

View File

@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
import type { DayEvent, ActivityBox } from './calendarTypes'; import type { DayEvent, ActivityBox } from './calendarTypes';
import type { WindowActivityInPeriod } from './activityTypes'; import type { WindowActivityInPeriod } from './activityTypes';
defineProps<{ const props = defineProps<{
dayStr: string; dayStr: string;
totalGridHeight: number; totalGridHeight: number;
hasActivityStatus: boolean; hasActivityStatus: boolean;
@@ -34,6 +34,8 @@ defineProps<{
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[]; getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
getActivityPercentage: (count: number, total: number) => string; getActivityPercentage: (count: number, total: number) => string;
getActivityText: (activity: WindowActivityInPeriod) => string; getActivityText: (activity: WindowActivityInPeriod) => string;
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
isDayView: boolean;
// Selection // Selection
showSelection: boolean; showSelection: boolean;
@@ -46,6 +48,16 @@ defineProps<{
selectionEndHeight: number; 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<{ const emit = defineEmits<{
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void; (e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
(e: 'event-keydown-enter', dayEvent: DayEvent): void; (e: 'event-keydown-enter', dayEvent: DayEvent): void;
@@ -55,6 +67,7 @@ const emit = defineEmits<{
dayEvent: DayEvent, dayEvent: DayEvent,
edge: 'start' | 'end' edge: 'start' | 'end'
): void; ): void;
(e: 'activity-pointerdown', event: PointerEvent): void;
}>(); }>();
</script> </script>
@@ -63,12 +76,16 @@ const emit = defineEmits<{
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none" class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
:class="{ :class="{
'has-activity-status': hasActivityStatus, 'has-activity-status': hasActivityStatus,
'activity-expanded': hasActivityStatus && isDayView,
}" }"
:data-date="dayStr" :data-date="dayStr"
:style="{ height: totalGridHeight + 'px' }"> :style="{ height: totalGridHeight + 'px' }">
<div <div
class="absolute inset-y-0 left-0.5 right-0.5" class="absolute inset-y-0 left-0.5 right-0.5"
:class="{ 'fc-events-inset': hasActivityStatus }"> :class="{
'fc-events-inset': hasActivityStatus && !isDayView,
'fc-events-inset-expanded': hasActivityStatus && isDayView,
}">
<div <div
v-for="dayEvent in dayEvents" v-for="dayEvent in dayEvents"
:key="dayEvent.event.id" :key="dayEvent.event.id"
@@ -120,15 +137,47 @@ const emit = defineEmits<{
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none" class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
:style="{ top: nowIndicatorTop + 'px' }"></div> :style="{ top: nowIndicatorTop + 'px' }"></div>
<TooltipProvider :delay-duration="0"> <TooltipProvider :disable-hoverable-content="true" :delay-duration="0">
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai"> <Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<div <div
class="activity-status-box" class="activity-status-box"
:class="abox.isIdle ? 'idle' : 'active'" :class="[
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div> 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)">
<div
v-if="
!abox.isIdle &&
getTopActivity(abox) &&
abox.height >= 16 &&
(isDayView || isUncoveredByEvents(abox))
"
class="activity-status-content">
<img
v-if="getTopActivity(abox)?.icon"
:src="getTopActivity(abox)!.icon!"
:alt="getTopActivity(abox)!.appName"
class="activity-status-icon" />
<div v-else class="activity-status-icon-fallback">
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
</div>
<span class="activity-status-label">
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
</span>
</div>
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" :side-offset="8"> <TooltipContent :side="isDayView ? 'right' : 'left'" :side-offset="8">
<template v-if="getActivityBoxActivities(abox).length === 0"> <template v-if="getActivityBoxActivities(abox).length === 0">
{{ getActivityBoxLabel(abox) }} {{ getActivityBoxLabel(abox) }}
</template> </template>
@@ -269,13 +318,99 @@ const emit = defineEmits<{
background-color: rgba(156, 163, 175, 0.5); background-color: rgba(156, 163, 175, 0.5);
} }
.activity-status-box.active::before { .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 { .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 { .fc-events-inset {
left: 8px; left: 8px;
} }
.fc-events-inset-expanded {
left: 204px;
}
</style> </style>

View File

@@ -163,6 +163,7 @@ const {
getActivityBoxActivities, getActivityBoxActivities,
getActivityPercentage, getActivityPercentage,
getActivityText, getActivityText,
getTopActivity,
} = useActivityBoxes({ } = useActivityBoxes({
activityPeriods: () => props.activityPeriods, activityPeriods: () => props.activityPeriods,
viewDays, 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 = () => { const scrollToCurrentTime = () => {
nextTick(() => { nextTick(() => {
if (!scrollerRef.value) return; if (!scrollerRef.value) return;
@@ -490,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
v-for="day in viewDays" v-for="day in viewDays"
:key="day.format('YYYY-MM-DD')" :key="day.format('YYYY-MM-DD')"
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center" class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
:class="{ :class="{
'bg-secondary': isToday(day), 'bg-secondary': isToday(day),
'fc-day-today': isToday(day), 'fc-day-today': isToday(day),
@@ -534,7 +551,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
class="flex-1 min-w-0 relative" class="flex-1 min-w-0 relative"
@pointerdown="onSlotPointerDown($event)"> @pointerdown="guardedSlotPointerDown($event)">
<div <div
class="bg-background relative" class="bg-background relative"
:style="{ height: totalGridHeight + 'px' }"> :style="{ height: totalGridHeight + 'px' }">
@@ -611,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:get-activity-box-activities="getActivityBoxActivities" :get-activity-box-activities="getActivityBoxActivities"
:get-activity-percentage="getActivityPercentage" :get-activity-percentage="getActivityPercentage"
:get-activity-text="getActivityText" :get-activity-text="getActivityText"
:get-top-activity="getTopActivity"
:is-day-view="activeView === 'timeGridDay'"
:show-selection=" :show-selection="
isSelecting || showCreateTimeEntryModal isSelecting || showCreateTimeEntryModal
" "
@@ -629,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:selection-height="selectionHeight" :selection-height="selectionHeight"
:selection-end-top="selectionEndTop" :selection-end-top="selectionEndTop"
:selection-end-height="selectionEndHeight" :selection-end-height="selectionEndHeight"
@activity-pointerdown="guardedSlotPointerDown"
@event-pointerdown=" @event-pointerdown="
(e, dayEvent) => (e, dayEvent) =>
onEventPointerDown(e, dayEvent.event, dayEvent) onEventPointerDown(e, dayEvent.event, dayEvent)

View File

@@ -21,10 +21,10 @@ export function useActivityBoxes(params: {
function getActivityBoxLabel(box: ActivityBox): string { function getActivityBoxLabel(box: ActivityBox): string {
const periodStart = getLocalizedDayJs(box.period.start); const periodStart = getLocalizedDayJs(box.period.start);
const periodEnd = getLocalizedDayJs(box.period.end); const periodEnd = getLocalizedDayJs(box.period.end);
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true)); const startText = periodStart.format('HH:mm');
const durationText = formatActivityDuration(durationMinutes); const endText = periodEnd.format('HH:mm');
const status = box.isIdle ? 'Idling' : 'Active'; const status = box.isIdle ? 'Idling' : 'Active';
return `${status} (${durationText})`; return `${status} (${startText} - ${endText})`;
} }
function getActivityBoxActivities(box: ActivityBox) { function getActivityBoxActivities(box: ActivityBox) {
@@ -40,6 +40,15 @@ export function useActivityBoxes(params: {
return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName; 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<WindowActivityInPeriod>(
(top, a) => (a.count > top.count ? a : top),
activities[0]!
);
}
const activityBoxes = computed<ActivityBox[]>(() => { const activityBoxes = computed<ActivityBox[]>(() => {
const periods = params.activityPeriods(); const periods = params.activityPeriods();
if (!periods || periods.length === 0) return []; if (!periods || periods.length === 0) return [];
@@ -99,5 +108,6 @@ export function useActivityBoxes(params: {
getActivityBoxActivities, getActivityBoxActivities,
getActivityPercentage, getActivityPercentage,
getActivityText, getActivityText,
getTopActivity,
}; };
} }

View File

@@ -29,7 +29,7 @@ export function useSlotSelection(params: {
function onSlotPointerDown(e: PointerEvent) { function onSlotPointerDown(e: PointerEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
const target = e.target as HTMLElement; 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); const dateStr = params.getDayFromClientX(e.clientX);
if (!dateStr) return; if (!dateStr) return;

View File

@@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...forwarded, ...$attrs }"
:class=" :class="
cn( 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 props.class
) )
"> ">