mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
show calendar activities more prominently when no time entry exists
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
Reference in New Issue
Block a user