mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -94,6 +94,7 @@ const emit = defineEmits<{
|
||||
getEventOpacityClass(dayEvent, dayStr),
|
||||
{
|
||||
'running-entry rounded-b-none': dayEvent.event.isRunning,
|
||||
'fc-event-break': dayEvent.event.isBreak,
|
||||
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
|
||||
'fc-event-resizing': resizeEventId === dayEvent.event.id,
|
||||
'rounded-t-none': dayEvent.isClippedStart,
|
||||
@@ -121,6 +122,8 @@ const emit = defineEmits<{
|
||||
:project-name="dayEvent.event.project?.name"
|
||||
:task-name="dayEvent.event.task?.name"
|
||||
:client-name="dayEvent.event.client?.name"
|
||||
:is-break="dayEvent.event.isBreak"
|
||||
:is-misplaced-break="dayEvent.event.isMisplacedBreak"
|
||||
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
|
||||
</div>
|
||||
<div
|
||||
@@ -413,4 +416,15 @@ const emit = defineEmits<{
|
||||
.fc-events-inset-expanded {
|
||||
left: 204px;
|
||||
}
|
||||
|
||||
/* Breaks get a hatched texture so they can not be confused with a project color */
|
||||
.fc-event-break {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
rgba(217, 119, 6, 0.15) 5px,
|
||||
rgba(217, 119, 6, 0.15) 7px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { Dayjs } from 'dayjs';
|
||||
const props = defineProps<{
|
||||
date: Dayjs;
|
||||
totalSeconds?: number;
|
||||
breakSeconds?: number;
|
||||
isToday?: boolean;
|
||||
}>();
|
||||
|
||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
||||
const breakSecondsValue = computed(() => props.breakSeconds ?? 0);
|
||||
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
@@ -24,6 +26,10 @@ const numberFormat = computed(() => organization?.value?.number_format);
|
||||
</div>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
|
||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||
<template v-if="breakSecondsValue > 0">
|
||||
· {{ formatHumanReadableDuration(breakSecondsValue, intervalFormat, numberFormat) }}
|
||||
break
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
@@ -11,6 +13,8 @@ const props = defineProps<{
|
||||
durationSeconds?: number;
|
||||
start?: string | Date | null;
|
||||
end?: string | Date | null;
|
||||
isBreak?: boolean;
|
||||
isMisplacedBreak?: boolean;
|
||||
}>();
|
||||
|
||||
const effectiveDurationSeconds = computed(() => {
|
||||
@@ -41,7 +45,15 @@ const formattedDuration = computed(() =>
|
||||
|
||||
<template>
|
||||
<div class="text-2xs leading-tight px-0.5 py-1">
|
||||
<div class="font-semibold">{{ title }}</div>
|
||||
<div class="font-semibold flex items-center gap-1">
|
||||
<Coffee v-if="isBreak" class="w-3 h-3 shrink-0" />
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<ExclamationTriangleIcon
|
||||
v-if="isMisplacedBreak"
|
||||
data-testid="calendar_break_placement_hint"
|
||||
title="This break does not align with your work entries"
|
||||
class="w-3 h-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div v-if="projectName" class="font-medium opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useCssVariable } from '../utils/useCssVariable';
|
||||
import { getLocalizedDayJs } from '../utils/time';
|
||||
import { useBreaksEnabled } from '../utils/useBreaksEnabled';
|
||||
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import BreakCreateModal from '../TimeEntry/BreakCreateModal.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import CalendarToolbar from './CalendarToolbar.vue';
|
||||
import CalendarDayColumn from './CalendarDayColumn.vue';
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
StopIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import type { ActivityPeriod } from './activityTypes';
|
||||
import { SLOT_HEIGHT, TIME_AXIS_WIDTH, type DayEvent } from './calendarTypes';
|
||||
import { useCalendarGrid } from './useCalendarGrid';
|
||||
@@ -74,6 +77,8 @@ const props = defineProps<{
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
organizationBillableRate: number | null;
|
||||
// Local date (YYYY-MM-DD) to open the calendar on, e.g. from a "Fix in calendar" deep link
|
||||
initialDate?: string | null;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
@@ -87,6 +92,9 @@ const props = defineProps<{
|
||||
|
||||
const newEventStart = ref<Dayjs | null>(null);
|
||||
const newEventEnd = ref<Dayjs | null>(null);
|
||||
const showCreateBreakModal = ref(false);
|
||||
const newBreakStart = ref<Dayjs | null>(null);
|
||||
const newBreakEnd = ref<Dayjs | null>(null);
|
||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
||||
const showEditTimeEntryModal = ref<boolean>(false);
|
||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
@@ -114,6 +122,7 @@ const currentTime = ref(getLocalizedDayJs());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const {
|
||||
slots,
|
||||
@@ -138,23 +147,34 @@ const {
|
||||
} = useCalendarNavigation({
|
||||
onDatesChange: (payload) => emit('dates-change', payload),
|
||||
scrollToCurrentTime: () => scrollToCurrentTime(),
|
||||
// Parse as local midnight in the user's timezone — getLocalizedDayJs would
|
||||
// treat the bare date as UTC midnight, landing on the previous local day
|
||||
// for negative UTC offsets
|
||||
initialDate: props.initialDate ? getLocalizedDayJsFromMinutes(props.initialDate, 0) : null,
|
||||
});
|
||||
|
||||
const cssBackground = useCssVariable('--color-bg-background');
|
||||
|
||||
const { optimisticOverrides, calendarEvents, eventsByDay, dailyTotals, isToday, nowIndicatorTop } =
|
||||
useCalendarEvents({
|
||||
timeEntries: () => props.timeEntries,
|
||||
projects: () => props.projects,
|
||||
clients: () => props.clients,
|
||||
tasks: () => props.tasks,
|
||||
calendarSettings,
|
||||
viewDays,
|
||||
currentTime,
|
||||
cssBackground,
|
||||
minutesToPixels,
|
||||
timeToMinutesFromMidnight,
|
||||
});
|
||||
const {
|
||||
optimisticOverrides,
|
||||
calendarEvents,
|
||||
eventsByDay,
|
||||
dailyTotals,
|
||||
dailyBreakTotals,
|
||||
isToday,
|
||||
nowIndicatorTop,
|
||||
} = useCalendarEvents({
|
||||
timeEntries: () => props.timeEntries,
|
||||
projects: () => props.projects,
|
||||
clients: () => props.clients,
|
||||
tasks: () => props.tasks,
|
||||
calendarSettings,
|
||||
viewDays,
|
||||
currentTime,
|
||||
cssBackground,
|
||||
minutesToPixels,
|
||||
timeToMinutesFromMidnight,
|
||||
});
|
||||
|
||||
const {
|
||||
activityBoxesForDay,
|
||||
@@ -244,6 +264,7 @@ const {
|
||||
handleContextStop,
|
||||
handleContextDiscard,
|
||||
handleContextCreate,
|
||||
handleContextCreateBreak,
|
||||
} = useContextMenu({
|
||||
calendarSettings,
|
||||
calendarEvents,
|
||||
@@ -262,6 +283,11 @@ const {
|
||||
newEventEnd.value = end;
|
||||
showCreateTimeEntryModal.value = true;
|
||||
},
|
||||
onCreateBreak: (start, end) => {
|
||||
newBreakStart.value = start;
|
||||
newBreakEnd.value = end;
|
||||
showCreateBreakModal.value = true;
|
||||
},
|
||||
emitRefresh: () => emit('refresh'),
|
||||
});
|
||||
|
||||
@@ -274,6 +300,14 @@ watch(showCreateTimeEntryModal, (value) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(showCreateBreakModal, (value) => {
|
||||
if (!value) {
|
||||
newBreakStart.value = null;
|
||||
newBreakEnd.value = null;
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
watch(showEditTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
selectedTimeEntry.value = null;
|
||||
@@ -455,6 +489,12 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
||||
|
||||
<BreakCreateModal
|
||||
v-model:show="showCreateBreakModal"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:start="newBreakStart ? newBreakStart.toISOString() : undefined"
|
||||
:end="newBreakEnd ? newBreakEnd.toISOString() : undefined" />
|
||||
|
||||
<TimeEntryEditModal
|
||||
v-model:show="showEditTimeEntryModal"
|
||||
:time-entry="selectedTimeEntry as any"
|
||||
@@ -516,8 +556,9 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<FullCalendarDayHeader
|
||||
:date="day"
|
||||
:is-today="isToday(day)"
|
||||
:total-seconds="
|
||||
dailyTotals[day.format('YYYY-MM-DD')] || 0
|
||||
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0"
|
||||
:break-seconds="
|
||||
dailyBreakTotals[day.format('YYYY-MM-DD')] || 0
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,11 +723,19 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Edit</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
|
||||
<!-- Duplicate/Split create a new entry of the same type, which the
|
||||
server rejects for breaks when breaks are disabled -->
|
||||
<ContextMenuItem
|
||||
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
||||
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()">
|
||||
<ContextMenuItem
|
||||
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
||||
class="space-x-3"
|
||||
@select="handleContextSplit()">
|
||||
<ScissorsIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Split</span>
|
||||
</ContextMenuItem>
|
||||
@@ -716,6 +765,13 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<PlusIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Create Time Entry</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="breaksEnabled"
|
||||
class="space-x-3"
|
||||
@select="handleContextCreateBreak()">
|
||||
<Coffee class="w-4 h-4 text-icon-default" />
|
||||
<span>Add Break</span>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CalendarEvent {
|
||||
client?: Client;
|
||||
task?: Task;
|
||||
isRunning: boolean;
|
||||
isBreak: boolean;
|
||||
isMisplacedBreak: boolean;
|
||||
durationMinutes: number;
|
||||
title: string;
|
||||
backgroundColor: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref, type Ref, type ComputedRef } from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
|
||||
import { getBreakPlacementHint } from '../utils/breakPlacement';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||
@@ -181,7 +182,8 @@ export function useCalendarEvents(params: {
|
||||
|
||||
const calendarEvents = computed<CalendarEvent[]>(() => {
|
||||
const themeBackground = params.cssBackground.value?.trim();
|
||||
return params.timeEntries().map((rawEntry) => {
|
||||
const allEntries = params.timeEntries();
|
||||
return allEntries.map((rawEntry) => {
|
||||
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
|
||||
const isRunning = timeEntry.end === null;
|
||||
const project = params.projects().find((p) => p.id === timeEntry.project_id);
|
||||
@@ -196,9 +198,20 @@ export function useCalendarEvents(params: {
|
||||
'minutes'
|
||||
);
|
||||
|
||||
const title = timeEntry.description || 'No description';
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const isBreak = timeEntry.type === 'break';
|
||||
const isMisplacedBreak = isBreak
|
||||
? (getBreakPlacementHint(timeEntry, allEntries)?.misplaced ?? false)
|
||||
: false;
|
||||
let title: string;
|
||||
if (isBreak) {
|
||||
title = timeEntry.description ? `Break · ${timeEntry.description}` : 'Break';
|
||||
} else {
|
||||
title = timeEntry.description || 'No description';
|
||||
}
|
||||
const baseColor = isBreak ? '#F59E0B' : project?.color || '#6B7280';
|
||||
const backgroundColor = chroma
|
||||
.mix(baseColor, themeBackground, isBreak ? 0.75 : 0.65, 'lab')
|
||||
.hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
@@ -215,6 +228,8 @@ export function useCalendarEvents(params: {
|
||||
client,
|
||||
task,
|
||||
isRunning,
|
||||
isBreak,
|
||||
isMisplacedBreak,
|
||||
durationMinutes,
|
||||
title,
|
||||
backgroundColor,
|
||||
@@ -253,28 +268,37 @@ export function useCalendarEvents(params: {
|
||||
return result;
|
||||
});
|
||||
|
||||
const dailyTotals = computed(() => {
|
||||
function computeDailyTotals(filter: (entry: TimeEntry) => boolean): Record<string, number> {
|
||||
const totals: Record<string, number> = {};
|
||||
params.timeEntries().forEach((entry) => {
|
||||
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
|
||||
let durationSeconds: number;
|
||||
params
|
||||
.timeEntries()
|
||||
.filter(filter)
|
||||
.forEach((entry) => {
|
||||
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
|
||||
let durationSeconds: number;
|
||||
|
||||
if (entry.end !== null) {
|
||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'seconds'
|
||||
);
|
||||
} else {
|
||||
durationSeconds = Math.max(
|
||||
0,
|
||||
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
|
||||
);
|
||||
}
|
||||
if (entry.end !== null) {
|
||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'seconds'
|
||||
);
|
||||
} else {
|
||||
durationSeconds = Math.max(
|
||||
0,
|
||||
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
|
||||
);
|
||||
}
|
||||
|
||||
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||
});
|
||||
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||
});
|
||||
return totals;
|
||||
});
|
||||
}
|
||||
|
||||
// Breaks are not working time: the day total only sums work entries,
|
||||
// the break portion is exposed separately
|
||||
const dailyTotals = computed(() => computeDailyTotals((entry) => entry.type !== 'break'));
|
||||
|
||||
const dailyBreakTotals = computed(() => computeDailyTotals((entry) => entry.type === 'break'));
|
||||
|
||||
function isToday(day: Dayjs): boolean {
|
||||
return day.isSame(getLocalizedDayJs(), 'day');
|
||||
@@ -294,6 +318,7 @@ export function useCalendarEvents(params: {
|
||||
calendarEvents,
|
||||
eventsByDay,
|
||||
dailyTotals,
|
||||
dailyBreakTotals,
|
||||
isToday,
|
||||
nowIndicatorTop,
|
||||
};
|
||||
|
||||
@@ -6,9 +6,10 @@ import { getWeekStartDayNumber } from '../utils/settings';
|
||||
export function useCalendarNavigation(callbacks: {
|
||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||
scrollToCurrentTime: () => void;
|
||||
initialDate?: Dayjs | null;
|
||||
}) {
|
||||
const activeView = ref('timeGridWeek');
|
||||
const currentDate = ref(getLocalizedDayJs());
|
||||
const currentDate = ref(callbacks.initialDate ?? getLocalizedDayJs());
|
||||
|
||||
function getFirstDay(): number {
|
||||
return getWeekStartDayNumber();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { CalendarEvent } from './calendarTypes';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
|
||||
function breakEntry(): TimeEntry {
|
||||
return {
|
||||
id: 'break-1',
|
||||
start: '2026-07-14T10:00:00Z',
|
||||
end: '2026-07-14T11:00:00Z',
|
||||
duration: 3600,
|
||||
description: 'Lunch',
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
organization_id: 'organization-1',
|
||||
user_id: 'user-1',
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'break',
|
||||
} as TimeEntry;
|
||||
}
|
||||
|
||||
describe('useContextMenu break actions', () => {
|
||||
const createTimeEntry = vi.fn().mockResolvedValue(undefined);
|
||||
const updateTimeEntry = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function contextMenu() {
|
||||
const entry = breakEntry();
|
||||
const calendarEvents = computed(() => [
|
||||
{
|
||||
id: entry.id,
|
||||
timeEntry: entry,
|
||||
} as CalendarEvent,
|
||||
]);
|
||||
const menu = useContextMenu({
|
||||
calendarSettings: ref({
|
||||
snapMinutes: 15,
|
||||
startHour: 0,
|
||||
endHour: 24,
|
||||
slotMinutes: 15,
|
||||
}),
|
||||
calendarEvents,
|
||||
pixelsToMinutesFromMidnight: () => 0,
|
||||
getDayFromClientX: () => null,
|
||||
clientYToGridPixels: () => 0,
|
||||
createTimeEntry,
|
||||
updateTimeEntry,
|
||||
deleteTimeEntry: vi.fn().mockResolvedValue(undefined),
|
||||
onEditEvent: vi.fn(),
|
||||
onCreateEvent: vi.fn(),
|
||||
onCreateBreak: vi.fn(),
|
||||
emitRefresh: vi.fn(),
|
||||
});
|
||||
|
||||
menu.handleCalendarContextMenu({
|
||||
target: {
|
||||
closest: () => ({
|
||||
getAttribute: () => entry.id,
|
||||
}),
|
||||
},
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
it('preserves the type when duplicating a break', async () => {
|
||||
await contextMenu().handleContextDuplicate();
|
||||
|
||||
expect(createTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the type when creating the second half of a split break', async () => {
|
||||
await contextMenu().handleContextSplit();
|
||||
|
||||
expect(updateTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
expect(createTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import type { CalendarEvent } from './calendarTypes';
|
||||
@@ -19,6 +19,7 @@ export function useContextMenu(params: {
|
||||
deleteTimeEntry: (id: string) => Promise<void>;
|
||||
onEditEvent: (entry: TimeEntry) => void;
|
||||
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
|
||||
onCreateBreak: (start: Dayjs, end: Dayjs) => void;
|
||||
emitRefresh: () => void;
|
||||
}) {
|
||||
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
|
||||
@@ -73,6 +74,7 @@ export function useContextMenu(params: {
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
@@ -108,6 +110,7 @@ export function useContextMenu(params: {
|
||||
start: midpoint.utc().format(),
|
||||
end: entry.end,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
@@ -154,6 +157,47 @@ export function useContextMenu(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextCreateBreak() {
|
||||
const dayjs = getDayJsInstance();
|
||||
if (!contextMenuCreateTime.value) {
|
||||
params.onCreateBreak(dayjs().utc().subtract(30, 'minute'), dayjs().utc());
|
||||
return;
|
||||
}
|
||||
const clickTime = contextMenuCreateTime.value.start;
|
||||
// Day matching must use the user's configured timezone (the calendar renders
|
||||
// its day columns in that timezone), not the browser's local timezone
|
||||
const clickDate = getLocalizedDayJs(clickTime.format()).format('YYYY-MM-DD');
|
||||
|
||||
// When the click lands in a gap between two entries of the same day,
|
||||
// the break is prefilled to exactly fill that gap
|
||||
let previousEnd: Dayjs | null = null;
|
||||
let nextStart: Dayjs | null = null;
|
||||
for (const calendarEvent of params.calendarEvents.value) {
|
||||
const entry = calendarEvent.timeEntry;
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
if (getLocalizedDayJs(entry.start).format('YYYY-MM-DD') !== clickDate) {
|
||||
continue;
|
||||
}
|
||||
const entryEnd = entry.end === null ? null : dayjs.utc(entry.end);
|
||||
if (entryEnd !== null && !entryEnd.isAfter(clickTime)) {
|
||||
if (previousEnd === null || entryEnd.isAfter(previousEnd)) {
|
||||
previousEnd = entryEnd;
|
||||
}
|
||||
}
|
||||
if (!entryStart.isBefore(clickTime)) {
|
||||
if (nextStart === null || entryStart.isBefore(nextStart)) {
|
||||
nextStart = entryStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousEnd !== null && nextStart !== null && previousEnd.isBefore(nextStart)) {
|
||||
params.onCreateBreak(previousEnd, nextStart);
|
||||
return;
|
||||
}
|
||||
params.onCreateBreak(contextMenuCreateTime.value.start, contextMenuCreateTime.value.end);
|
||||
}
|
||||
|
||||
return {
|
||||
contextMenuTimeEntry,
|
||||
contextMenuCreateTime,
|
||||
@@ -165,5 +209,6 @@ export function useContextMenu(params: {
|
||||
handleContextStop,
|
||||
handleContextDiscard,
|
||||
handleContextCreate,
|
||||
handleContextCreateBreak,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user