only show calendar toolbar after load complete to avoid layout shift

This commit is contained in:
Gregor Vostrak
2026-03-11 18:09:50 +01:00
parent 19c789b78e
commit ef7569b63b
4 changed files with 216 additions and 203 deletions

View File

@@ -497,10 +497,13 @@ test.describe('Visual Snapping', () => {
await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
// Scroll so both the event (9:00) and target (14:00) are in viewport
await scrollCalendarToTime(page, '08:00:00');
const event = page.locator('.fc-event').first(); const event = page.locator('.fc-event').first();
await expect(event).toBeVisible(); await expect(event).toBeVisible();
// Drag event // Capture target coordinates after scroll is settled
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox(); const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull(); expect(targetBox).not.toBeNull();

View File

@@ -1427,10 +1427,7 @@ test('test that bare integer in inline duration input with decimal format is int
}); });
}); });
test('test that bare integer in create modal is interpreted as minutes', async ({ test('test that bare integer in create modal is interpreted as minutes', async ({ page, ctx }) => {
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { await updateOrganizationSettingViaApi(ctx, {
interval_format: 'hours-minutes', interval_format: 'hours-minutes',
number_format: 'comma-point', number_format: 'comma-point',

View File

@@ -440,216 +440,233 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:currency="currency" :currency="currency"
:can-create-project="canCreateProject" /> :can-create-project="canCreateProject" />
<CalendarToolbar <template v-if="!loading">
:view-title="viewTitle" <CalendarToolbar
:active-view="activeView" :view-title="viewTitle"
:settings="calendarSettings" :active-view="activeView"
@prev="handlePrev" :settings="calendarSettings"
@next="handleNext" @prev="handlePrev"
@today="handleToday" @next="handleNext"
@change-view="handleChangeView" @today="handleToday"
@update:settings="onSettingsUpdate" /> @change-view="handleChangeView"
@update:settings="onSettingsUpdate" />
<ContextMenu v-model:open="contextMenuOpen"> <ContextMenu v-model:open="contextMenuOpen">
<ContextMenuTrigger <ContextMenuTrigger
as="div" as="div"
class="flex-1 min-h-0" class="flex-1 min-h-0"
@contextmenu="handleCalendarContextMenu"> @contextmenu="handleCalendarContextMenu">
<div
ref="rootRef"
class="fc h-full flex flex-col bg-default-background text-foreground font-inherit border border-border border-l-transparent overflow-hidden">
<div <div
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background"> ref="rootRef"
class="fc h-full flex flex-col bg-default-background text-foreground font-inherit border border-border border-l-transparent overflow-hidden">
<div <div
class="shrink-0 bg-background border-r border-border" class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
:style="{
width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px',
}"></div>
<div
class="grid flex-1 min-w-0"
:style="{
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
}">
<div
v-for="day in viewDays"
: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="{
'bg-secondary': isToday(day),
'fc-day-today': isToday(day),
}"
:data-date="day.format('YYYY-MM-DD')">
<FullCalendarDayHeader
:date="day"
:is-today="isToday(day)"
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0" />
</div>
</div>
</div>
<div ref="scrollerRef" class="fc-scroller">
<div class="flex min-w-0">
<div <div
class="shrink-0 bg-background border-r border-border" class="shrink-0 bg-background border-r border-border"
:style="{ :style="{
width: TIME_AXIS_WIDTH + 'px', width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px', minWidth: TIME_AXIS_WIDTH + 'px',
}"></div>
<div
class="grid flex-1 min-w-0"
:style="{
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
}"> }">
<div <div
v-for="slot in slots" v-for="day in viewDays"
:key="slot.time" :key="day.format('YYYY-MM-DD')"
class="fc-timegrid-slot fc-timegrid-slot-label relative text-right border-t border-border pr-1.5 pt-2 box-border" class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
:class="{ :class="{
'fc-timegrid-slot-minor border-t-transparent': !slot.isHour, 'bg-secondary': isToday(day),
'fc-day-today': isToday(day),
}" }"
:data-time="slot.time" :data-date="day.format('YYYY-MM-DD')">
:style="{ height: SLOT_HEIGHT + 'px' }"> <FullCalendarDayHeader
<span :date="day"
v-if="slot.isHour" :is-today="isToday(day)"
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block"> :total-seconds="
{{ formatSlotLabel(slot.minutes / 60) }} dailyTotals[day.format('YYYY-MM-DD')] || 0
</span>
</div>
</div>
<div
class="flex-1 min-w-0 relative"
@pointerdown="onSlotPointerDown($event)">
<div
class="bg-background"
:style="{ height: totalGridHeight + 'px' }">
<div
v-for="slot in slots"
:key="'lane-' + slot.time"
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
:class="{
'fc-timegrid-slot-minor border-dotted': !slot.isHour,
}"
:data-time="slot.time"
:style="{ height: SLOT_HEIGHT + 'px' }"></div>
</div>
<div
class="grid absolute inset-0 pointer-events-none min-w-0"
:style="{
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
}">
<CalendarDayColumn
v-for="day in viewDays"
:key="day.format('YYYY-MM-DD')"
:day-str="day.format('YYYY-MM-DD')"
:total-grid-height="totalGridHeight"
:has-activity-status="
dayHasActivityStatus(day.format('YYYY-MM-DD'))
"
:day-events="eventsByDay[day.format('YYYY-MM-DD')] || []"
:get-event-style="getEventStyle"
:get-event-opacity-class="getEventOpacityClass"
:get-event-duration-seconds="getEventDurationSeconds"
:is-dragging="isDragging"
:drag-event-id="dragEventId"
:drag-preview="dragPreviewsByDay[day.format('YYYY-MM-DD')]"
:resize-event-id="resizeEventId"
:resize-cross-day-preview="
isResizing
? resizeCrossDayPreviewsByDay[
day.format('YYYY-MM-DD')
]
: undefined
"
:show-now-indicator="isToday(day) && nowIndicatorTop >= 0"
:now-indicator-top="nowIndicatorTop"
:activity-boxes="
activityBoxesForDay(day.format('YYYY-MM-DD'))
"
:get-activity-box-label="getActivityBoxLabel"
:get-activity-box-activities="getActivityBoxActivities"
:get-activity-percentage="getActivityPercentage"
:get-activity-text="getActivityText"
:show-selection="isSelecting || showCreateTimeEntryModal"
:is-selection-start="
selectionDay === day.format('YYYY-MM-DD')
"
:is-selection-intermediate="
selectionIntermediateDays.has(day.format('YYYY-MM-DD'))
"
:is-selection-end="
selectionEndDay === day.format('YYYY-MM-DD')
"
:selection-top="selectionTop"
:selection-height="selectionHeight"
:selection-end-top="selectionEndTop"
:selection-end-height="selectionEndHeight"
@event-pointerdown="
(e, dayEvent) =>
onEventPointerDown(e, dayEvent.event, dayEvent)
"
@event-keydown-enter="
(dayEvent) => {
selectedTimeEntry = dayEvent.event.timeEntry;
showEditTimeEntryModal = true;
}
"
@resizer-pointerdown="
(e, dayEvent, edge) =>
onResizerPointerDown(
e,
dayEvent.event,
dayEvent,
edge,
day.format('YYYY-MM-DD')
)
" /> " />
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]"> <div ref="scrollerRef" class="fc-scroller">
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null"> <div class="flex min-w-0">
<ContextMenuItem class="space-x-3" @select="handleContextEdit()"> <div
<PencilIcon class="w-4 h-4 text-icon-default" /> class="shrink-0 bg-background border-r border-border"
<span>Edit</span> :style="{
</ContextMenuItem> width: TIME_AXIS_WIDTH + 'px',
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()"> minWidth: TIME_AXIS_WIDTH + 'px',
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" /> }">
<span>Duplicate</span> <div
</ContextMenuItem> v-for="slot in slots"
<ContextMenuItem class="space-x-3" @select="handleContextSplit()"> :key="slot.time"
<ScissorsIcon class="w-4 h-4 text-icon-default" /> class="fc-timegrid-slot fc-timegrid-slot-label relative text-right border-t border-border pr-1.5 pt-2 box-border"
<span>Split</span> :class="{
</ContextMenuItem> 'fc-timegrid-slot-minor border-t-transparent':
<ContextMenuSeparator /> !slot.isHour,
<ContextMenuItem }"
class="space-x-3 text-destructive" :data-time="slot.time"
@select="handleContextDelete()"> :style="{ height: SLOT_HEIGHT + 'px' }">
<TrashIcon class="w-4 h-4 text-icon-default" /> <span
<span>Delete</span> v-if="slot.isHour"
</ContextMenuItem> class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
</template> {{ formatSlotLabel(slot.minutes / 60) }}
<template v-else-if="contextMenuTimeEntry && contextMenuTimeEntry.end === null"> </span>
<ContextMenuItem class="space-x-3" @select="handleContextStop()"> </div>
<StopIcon class="w-4 h-4 text-icon-default" /> </div>
<span>Stop</span>
</ContextMenuItem> <div
<ContextMenuSeparator /> class="flex-1 min-w-0 relative"
<ContextMenuItem @pointerdown="onSlotPointerDown($event)">
class="space-x-3 text-destructive" <div
@select="handleContextDiscard()"> class="bg-background"
<XMarkIcon class="w-4 h-4 text-icon-default" /> :style="{ height: totalGridHeight + 'px' }">
<span>Discard</span> <div
</ContextMenuItem> v-for="slot in slots"
</template> :key="'lane-' + slot.time"
<template v-else> class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
<ContextMenuItem class="space-x-3" @select="handleContextCreate()"> :class="{
<PlusIcon class="w-4 h-4 text-icon-default" /> 'fc-timegrid-slot-minor border-dotted':
<span>Create Time Entry</span> !slot.isHour,
</ContextMenuItem> }"
</template> :data-time="slot.time"
</ContextMenuContent> :style="{ height: SLOT_HEIGHT + 'px' }"></div>
</ContextMenu> </div>
<div
class="grid absolute inset-0 pointer-events-none min-w-0"
:style="{
gridTemplateColumns:
'repeat(' + viewDays.length + ', 1fr)',
}">
<CalendarDayColumn
v-for="day in viewDays"
:key="day.format('YYYY-MM-DD')"
:day-str="day.format('YYYY-MM-DD')"
:total-grid-height="totalGridHeight"
:has-activity-status="
dayHasActivityStatus(day.format('YYYY-MM-DD'))
"
:day-events="
eventsByDay[day.format('YYYY-MM-DD')] || []
"
:get-event-style="getEventStyle"
:get-event-opacity-class="getEventOpacityClass"
:get-event-duration-seconds="getEventDurationSeconds"
:is-dragging="isDragging"
:drag-event-id="dragEventId"
:drag-preview="
dragPreviewsByDay[day.format('YYYY-MM-DD')]
"
:resize-event-id="resizeEventId"
:resize-cross-day-preview="
isResizing
? resizeCrossDayPreviewsByDay[
day.format('YYYY-MM-DD')
]
: undefined
"
:show-now-indicator="
isToday(day) && nowIndicatorTop >= 0
"
:now-indicator-top="nowIndicatorTop"
:activity-boxes="
activityBoxesForDay(day.format('YYYY-MM-DD'))
"
:get-activity-box-label="getActivityBoxLabel"
:get-activity-box-activities="getActivityBoxActivities"
:get-activity-percentage="getActivityPercentage"
:get-activity-text="getActivityText"
:show-selection="
isSelecting || showCreateTimeEntryModal
"
:is-selection-start="
selectionDay === day.format('YYYY-MM-DD')
"
:is-selection-intermediate="
selectionIntermediateDays.has(
day.format('YYYY-MM-DD')
)
"
:is-selection-end="
selectionEndDay === day.format('YYYY-MM-DD')
"
:selection-top="selectionTop"
:selection-height="selectionHeight"
:selection-end-top="selectionEndTop"
:selection-end-height="selectionEndHeight"
@event-pointerdown="
(e, dayEvent) =>
onEventPointerDown(e, dayEvent.event, dayEvent)
"
@event-keydown-enter="
(dayEvent) => {
selectedTimeEntry = dayEvent.event.timeEntry;
showEditTimeEntryModal = true;
}
"
@resizer-pointerdown="
(e, dayEvent, edge) =>
onResizerPointerDown(
e,
dayEvent.event,
dayEvent,
edge,
day.format('YYYY-MM-DD')
)
" />
</div>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null">
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
<ScissorsIcon class="w-4 h-4 text-icon-default" />
<span>Split</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
class="space-x-3 text-destructive"
@select="handleContextDelete()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</template>
<template v-else-if="contextMenuTimeEntry && contextMenuTimeEntry.end === null">
<ContextMenuItem class="space-x-3" @select="handleContextStop()">
<StopIcon class="w-4 h-4 text-icon-default" />
<span>Stop</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
class="space-x-3 text-destructive"
@select="handleContextDiscard()">
<XMarkIcon class="w-4 h-4 text-icon-default" />
<span>Discard</span>
</ContextMenuItem>
</template>
<template v-else>
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
<PlusIcon class="w-4 h-4 text-icon-default" />
<span>Create Time Entry</span>
</ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenu>
</template>
</div> </div>
</template> </template>

View File

@@ -26,11 +26,7 @@ function updateDuration() {
return; return;
} }
const seconds = parseTimeInput( const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
input,
organization?.value?.number_format,
'hours'
);
if (seconds !== null && seconds > 0) { if (seconds !== null && seconds > 0) {
model.value = seconds; model.value = seconds;
} }