From 0791a68283132453a7b4b117746f0ccb7efbce12 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 19 Nov 2025 16:08:32 +0100 Subject: [PATCH] add support for currently running time entry --- .../ui/src/FullCalendar/TimeEntryCalendar.vue | 130 ++++++++++++------ 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 8d12265c..82307811 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -13,6 +13,7 @@ import { nextTick, onMounted, onActivated, + onUnmounted, } from 'vue'; import chroma from 'chroma-js'; import { useCssVariable } from '@/utils/useCssVariable'; @@ -37,7 +38,10 @@ import type { } from '@/packages/api/src'; import type { Dayjs } from 'dayjs'; -type CalendarExtendedProps = { timeEntry: TimeEntry } & Record; +type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record< + string, + unknown +>; const emit = defineEmits<{ (e: 'dates-change', payload: { start: Date; end: Date }): void; @@ -77,6 +81,10 @@ const selectedTimeEntry = ref(null); const calendarRef = ref | null>(null); +// Reactive "now" for running time entry - updates every minute +const currentTime = ref(getDayJsInstance()()); +let currentTimeInterval: ReturnType | null = null; + // Inject organization data for settings const organization = inject>('organization'); @@ -118,47 +126,52 @@ const events = computed(() => { const themeBackground = (() => { return cssBackground.value?.trim(); })(); - return props.timeEntries - ?.filter((timeEntry) => timeEntry.end !== null) - ?.map((timeEntry) => { - const project = props.projects.find((p) => p.id === timeEntry.project_id); - const client = props.clients.find((c) => c.id === project?.client_id); - const task = props.tasks.find((t) => t.id === timeEntry.task_id); - const duration = getDayJsInstance()(timeEntry.end!).diff( - getDayJsInstance()(timeEntry.start), - 'minutes' - ); + return props.timeEntries?.map((timeEntry) => { + const isRunning = timeEntry.end === null; + const project = props.projects.find((p) => p.id === timeEntry.project_id); + const client = props.clients.find((c) => c.id === project?.client_id); + const task = props.tasks.find((t) => t.id === timeEntry.task_id); - const title = timeEntry.description || 'No description'; + // For running entries, use current time as end + const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!); + const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes'); - const baseColor = project?.color || '#6B7280'; - const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex(); - const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex(); + const title = timeEntry.description || 'No description'; - // For 0-duration events, display them with minimum visual duration but preserve actual duration - const startTime = getLocalizedDayJs(timeEntry.start); - const endTime = - duration === 0 - ? startTime.add(1, 'second') // Show as 1 second for minimal visibility - : getLocalizedDayJs(timeEntry.end!); + const baseColor = project?.color || '#6B7280'; + const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex(); + const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex(); - return { - id: timeEntry.id, - start: startTime.format(), - end: endTime.format(), - title, - backgroundColor, - borderColor, - textColor: 'var(--foreground)', - extendedProps: { - timeEntry, - project, - client, - task, - duration, - }, - }; - }); + // For 0-duration events, display them with minimum visual duration but preserve actual duration + const startTime = getLocalizedDayJs(timeEntry.start); + const endTime = + duration === 0 + ? startTime.add(1, 'second') // Show as 1 second for minimal visibility + : isRunning + ? getLocalizedDayJs(currentTime.value.toISOString()) + : getLocalizedDayJs(timeEntry.end!); + + return { + id: timeEntry.id, + start: startTime.format(), + end: endTime.format(), + title, + backgroundColor, + borderColor, + textColor: 'var(--foreground)', + // For running entries: disable dragging and resizing + startEditable: !isRunning, + classNames: isRunning ? ['running-entry'] : [], + extendedProps: { + timeEntry, + project, + client, + task, + duration, + isRunning, + }, + }; + }); }); // Daily totals used in day header @@ -199,6 +212,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) { function handleEventClick(arg: EventClickArg) { const ext = arg.event.extendedProps as CalendarExtendedProps; + // Don't open edit modal for running time entries + if (ext.isRunning) { + return; + } selectedTimeEntry.value = ext.timeEntry; showEditTimeEntryModal.value = true; } @@ -238,12 +255,15 @@ async function handleEventResize(arg: EventChangeArg) { .second(0) .utc() .format(), - end: getDayJsInstance()(arg.event.end.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .second(0) - .utc() - .format(), + // Preserve null end for running entries + end: ext.isRunning + ? null + : getDayJsInstance()(arg.event.end.toISOString()) + .utc() + .tz(getUserTimezone(), true) + .second(0) + .utc() + .format(), } as TimeEntry; await props.updateTimeEntry(updatedTimeEntry); emit('refresh'); @@ -337,11 +357,23 @@ const scrollToCurrentTime = () => { onMounted(() => { scrollToCurrentTime(); + // Start interval to update running time entry + currentTimeInterval = setInterval(() => { + currentTime.value = getDayJsInstance()(); + }, 60000); // Update every minute }); onActivated(() => { scrollToCurrentTime(); }); + +onUnmounted(() => { + // Clean up interval + if (currentTimeInterval) { + clearInterval(currentTimeInterval); + currentTimeInterval = null; + } +});