mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
add calendar settings + custom visual snapping
This commit is contained in:
@@ -15,13 +15,22 @@ import {
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
formatHumanReadableDuration,
|
||||
formatDuration,
|
||||
} from '../utils/time';
|
||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import { useVisualSnap } from './useVisualSnap';
|
||||
import activityStatusPlugin, {
|
||||
type ActivityPeriod,
|
||||
renderActivityStatusBoxes,
|
||||
@@ -81,6 +90,22 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Calendar settings with localStorage persistence via VueUse
|
||||
const calendarSettings = useLocalStorage<CalendarSettings>(
|
||||
'solidtime:calendar-settings',
|
||||
{
|
||||
snapMinutes: 15,
|
||||
startHour: 0,
|
||||
endHour: 24,
|
||||
slotMinutes: 15,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
function onSettingsUpdate(newSettings: CalendarSettings) {
|
||||
calendarSettings.value = newSettings;
|
||||
}
|
||||
|
||||
// Reactive "now" for running time entry - updates every minute
|
||||
const currentTime = ref(getDayJsInstance()());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) {
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
||||
stopVisualSnap();
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
const startLocal = getDayJsInstance()(arg.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
newEventStart.value = startTime;
|
||||
newEventEnd.value = endTime;
|
||||
.tz(getUserTimezone(), true);
|
||||
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
|
||||
const snappedStart = snapToGrid(startLocal, snap);
|
||||
let snappedEnd = snapToGrid(endLocal, snap);
|
||||
if (!snappedEnd.isAfter(snappedStart)) {
|
||||
snappedEnd = snappedStart.add(snap, 'minute');
|
||||
}
|
||||
newEventStart.value = snappedStart.utc();
|
||||
newEventEnd.value = snappedEnd.utc();
|
||||
showCreateTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
@@ -227,90 +255,136 @@ function handleEventClick(arg: EventClickArg) {
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
// Snap a dayjs time to the nearest snap interval boundary
|
||||
function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||
const minutes = time.hour() * 60 + time.minute();
|
||||
const snapped = Math.round(minutes / snapMinutes) * snapMinutes;
|
||||
return time.startOf('day').add(snapped, 'minute');
|
||||
}
|
||||
|
||||
// --- Visual snap (composable) ---
|
||||
const {
|
||||
startDragSnap: startVisualDragSnap,
|
||||
startResizeSnap: startVisualResizeSnap,
|
||||
stop: stopVisualSnap,
|
||||
} = useVisualSnap({
|
||||
calendarRef,
|
||||
snapMinutes: () => calendarSettings.value.snapMinutes,
|
||||
slotMinutes: () => calendarSettings.value.slotMinutes,
|
||||
formatDuration: (seconds) =>
|
||||
formatHumanReadableDuration(
|
||||
seconds,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
),
|
||||
});
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
stopVisualSnap();
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
// Running entries have no end time — can't compute duration for drop
|
||||
if (!timeEntry.end) return;
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
const startLocal = getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const snappedStart = snapToGrid(startLocal, snap);
|
||||
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
|
||||
const snappedEnd = snappedStart.add(durationMs, 'millisecond');
|
||||
// Set FC event to snapped position immediately to avoid flash
|
||||
arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate());
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
start: snappedStart.utc().format(),
|
||||
end: snappedEnd.utc().format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventChangeArg) {
|
||||
stopVisualSnap();
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
|
||||
const newStartLocal = getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const newEndLocal = getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0);
|
||||
|
||||
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
|
||||
|
||||
// Snap only the changed edge once, reuse for both setDates and API update
|
||||
const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null;
|
||||
const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null;
|
||||
|
||||
// Set FC event to snapped position immediately to avoid flash.
|
||||
// Use the original event date for the edge that wasn't resized.
|
||||
if (snappedStart) {
|
||||
arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!);
|
||||
} else if (snappedEnd) {
|
||||
arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate());
|
||||
}
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.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(),
|
||||
start: snappedStart ? snappedStart.utc().format() : timeEntry.start,
|
||||
end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: getUserTimezone(),
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
const calendarOptions = computed(() => {
|
||||
const s = calendarSettings.value;
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
}));
|
||||
return {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: formatDuration(s.startHour * 3600),
|
||||
slotMaxTime: formatDuration(s.endHour * 3600),
|
||||
slotDuration: formatDuration(s.slotMinutes * 60),
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: getUserTimezone(),
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDragStart: startVisualDragSnap,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResizeStart: startVisualResizeSnap,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
};
|
||||
});
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
@@ -376,7 +450,6 @@ onActivated(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up interval
|
||||
if (currentTimeInterval) {
|
||||
clearInterval(currentTimeInterval);
|
||||
currentTimeInterval = null;
|
||||
@@ -424,6 +497,11 @@ onUnmounted(() => {
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
<div class="calendar-settings-trigger">
|
||||
<CalendarSettingsPopover
|
||||
:settings="calendarSettings"
|
||||
@update:settings="onSettingsUpdate" />
|
||||
</div>
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
@@ -458,6 +536,13 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-settings-trigger {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fullcalendar {
|
||||
height: 100%;
|
||||
--fc-border-color: var(--border);
|
||||
@@ -482,6 +567,7 @@ onUnmounted(() => {
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--background);
|
||||
padding: 0.5rem;
|
||||
padding-right: 2.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -580,36 +666,65 @@ onUnmounted(() => {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Enhanced FullCalendar resize handles */
|
||||
/* Resize handle hit areas */
|
||||
.fullcalendar :deep(.fc-event-resizer) {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
background: '#FFF';
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
height: 12px;
|
||||
left: 0;
|
||||
transition: all 0.2s ease;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-start) {
|
||||
top: -2px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-end) {
|
||||
bottom: -2px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
/* Visual grip indicator */
|
||||
.fullcalendar :deep(.fc-event-resizer::after) {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer:hover) {
|
||||
background: '#FFF';
|
||||
height: 6px;
|
||||
.fullcalendar :deep(.fc-event-resizer:hover::after) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Keep resize cursor during active resize */
|
||||
.fullcalendar :deep(.fc-event-resizing),
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
|
||||
/* Keep event in hover state while resizing */
|
||||
.fullcalendar :deep(.fc-event-resizing) {
|
||||
opacity: 1;
|
||||
box-shadow: var(--theme-shadow-dropdown);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer::after) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Update the earlier hover rule to include the shadow */
|
||||
@@ -763,3 +878,11 @@ onUnmounted(() => {
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Global cursor override during resize — must be unscoped to affect body */
|
||||
body.fc-resizing-active,
|
||||
body.fc-resizing-active * {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user