mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
add calendar settings + custom visual snapping
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
async function goToCalendar(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openSettingsPopover(page: Page) {
|
||||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||||
}
|
||||
|
||||
async function clearCalendarSettings(page: Page) {
|
||||
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
|
||||
}
|
||||
|
||||
test.describe('Calendar Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await clearCalendarSettings(page);
|
||||
});
|
||||
|
||||
test('settings popover shows all fields with correct defaults', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
|
||||
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
|
||||
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
|
||||
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
|
||||
});
|
||||
|
||||
test('snap interval can be changed and persists across reload', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Change snap interval to 30 min
|
||||
await page.getByLabel('Snap Interval').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Verify localStorage was updated
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
|
||||
);
|
||||
expect(stored.snapMinutes).toBe(30);
|
||||
|
||||
// Reload and verify persistence
|
||||
await page.reload();
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
await openSettingsPopover(page);
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
||||
});
|
||||
|
||||
test('start time change is applied to calendar and rejects values >= end time', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify 7 AM slot exists with default start (00:00)
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Set end time to 6 PM first
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
|
||||
// Change start time to 8 AM (valid)
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Calendar should no longer show hours before 8 AM
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
// Try to set start time to 6 PM (invalid: equals end time)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
|
||||
// Should be rejected — start time stays at 8 AM
|
||||
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
|
||||
});
|
||||
|
||||
test('end time change is applied to calendar and rejects values <= start time', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify 19:00 slot exists with default end (24:00)
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Set start time to 8 AM first
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
|
||||
// Change end time to 6 PM (valid)
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Calendar should no longer show hours at or after 6 PM
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
// Try to set end time to 8 AM (invalid: equals start time)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
|
||||
// Should be rejected — end time stays at 6 PM
|
||||
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
|
||||
});
|
||||
|
||||
test('grid scale affects number of calendar slots', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Count slots with default 15-min scale
|
||||
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
|
||||
// Change to 30 min scale (should halve the slots)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
|
||||
|
||||
// Change to 5 min scale (should have many more slots)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
|
||||
});
|
||||
|
||||
test('all settings persist across navigation', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Change every setting
|
||||
await page.getByLabel('Snap Interval').click();
|
||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '6:00 AM' }).click();
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '10:00 PM' }).click();
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Navigate away and back
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify all settings persisted
|
||||
await openSettingsPopover(page);
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
|
||||
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
|
||||
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
|
||||
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { Settings } from 'lucide-vue-next';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
|
||||
export type { CalendarSettings };
|
||||
|
||||
const props = defineProps<{
|
||||
settings: CalendarSettings;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:settings': [value: CalendarSettings];
|
||||
}>();
|
||||
|
||||
const snapMinutes = ref(String(props.settings.snapMinutes));
|
||||
const startHour = ref(String(props.settings.startHour));
|
||||
const endHour = ref(String(props.settings.endHour));
|
||||
const slotMinutes = ref(String(props.settings.slotMinutes));
|
||||
|
||||
watch(
|
||||
() => props.settings,
|
||||
(s) => {
|
||||
snapMinutes.value = String(s.snapMinutes);
|
||||
startHour.value = String(s.startHour);
|
||||
endHour.value = String(s.endHour);
|
||||
slotMinutes.value = String(s.slotMinutes);
|
||||
}
|
||||
);
|
||||
|
||||
function emitUpdate(partial: Partial<CalendarSettings>) {
|
||||
emit('update:settings', { ...props.settings, ...partial });
|
||||
}
|
||||
|
||||
function onSnapChange(value: string) {
|
||||
snapMinutes.value = value;
|
||||
emitUpdate({ snapMinutes: parseInt(value) });
|
||||
}
|
||||
|
||||
function onStartHourChange(value: string) {
|
||||
const newStart = parseInt(value);
|
||||
// Ensure start < end
|
||||
if (newStart >= parseInt(endHour.value)) {
|
||||
startHour.value = String(props.settings.startHour);
|
||||
return;
|
||||
}
|
||||
startHour.value = value;
|
||||
emitUpdate({ startHour: newStart });
|
||||
}
|
||||
|
||||
function onEndHourChange(value: string) {
|
||||
const newEnd = parseInt(value);
|
||||
// Ensure end > start
|
||||
if (newEnd <= parseInt(startHour.value)) {
|
||||
endHour.value = String(props.settings.endHour);
|
||||
return;
|
||||
}
|
||||
endHour.value = value;
|
||||
emitUpdate({ endHour: newEnd });
|
||||
}
|
||||
|
||||
function onSlotChange(value: string) {
|
||||
slotMinutes.value = value;
|
||||
emitUpdate({ slotMinutes: parseInt(value) });
|
||||
}
|
||||
|
||||
const snapOptions = [
|
||||
{ value: '1', label: '1 min' },
|
||||
{ value: '5', label: '5 min' },
|
||||
{ value: '10', label: '10 min' },
|
||||
{ value: '15', label: '15 min' },
|
||||
{ value: '30', label: '30 min' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
];
|
||||
|
||||
const slotOptions = [
|
||||
{ value: '5', label: '5 min' },
|
||||
{ value: '10', label: '10 min' },
|
||||
{ value: '15', label: '15 min' },
|
||||
{ value: '30', label: '30 min' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
];
|
||||
|
||||
// Generate hour options 0-24
|
||||
const hourOptions = Array.from({ length: 25 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label:
|
||||
i === 0
|
||||
? '12:00 AM'
|
||||
: i === 12
|
||||
? '12:00 PM'
|
||||
: i === 24
|
||||
? '12:00 AM (next)'
|
||||
: i < 12
|
||||
? `${i}:00 AM`
|
||||
: `${i - 12}:00 PM`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" aria-label="Calendar settings" class="h-8 w-8 p-0">
|
||||
<Settings class="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 p-4">
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm font-semibold">Calendar Settings</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-snap">Snap Interval</FieldLabel>
|
||||
<Select
|
||||
:model-value="snapMinutes"
|
||||
@update:model-value="(v) => onSnapChange(v as string)">
|
||||
<SelectTrigger id="calendar-snap" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Snap interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in snapOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-start-hour">Start Time</FieldLabel>
|
||||
<Select
|
||||
:model-value="startHour"
|
||||
@update:model-value="(v) => onStartHourChange(v as string)">
|
||||
<SelectTrigger id="calendar-start-hour" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Start time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in hourOptions.slice(0, -1)"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-end-hour">End Time</FieldLabel>
|
||||
<Select
|
||||
:model-value="endHour"
|
||||
@update:model-value="(v) => onEndHourChange(v as string)">
|
||||
<SelectTrigger id="calendar-end-hour" size="sm" class="w-full">
|
||||
<SelectValue placeholder="End time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in hourOptions.slice(1)"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-scale">Grid Scale</FieldLabel>
|
||||
<Select
|
||||
:model-value="slotMinutes"
|
||||
@update:model-value="(v) => onSlotChange(v as string)">
|
||||
<SelectTrigger id="calendar-scale" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Grid scale" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in slotOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface CalendarSettings {
|
||||
snapMinutes: number;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
slotMinutes: number;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import type FullCalendar from '@fullcalendar/vue3';
|
||||
|
||||
interface VisualSnapOptions {
|
||||
calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;
|
||||
snapMinutes: () => number;
|
||||
slotMinutes: () => number;
|
||||
formatDuration: (durationSeconds: number) => string;
|
||||
}
|
||||
|
||||
export function useVisualSnap({
|
||||
calendarRef,
|
||||
snapMinutes,
|
||||
slotMinutes,
|
||||
formatDuration,
|
||||
}: VisualSnapOptions) {
|
||||
let rafId: number | null = null;
|
||||
|
||||
function getCalendarEl(): HTMLElement | null {
|
||||
return (calendarRef.value?.$el as HTMLElement) ?? null;
|
||||
}
|
||||
|
||||
function getSnapPixels(): number {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return 25;
|
||||
const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;
|
||||
if (!slot) return 25;
|
||||
const slotHeightPx = slot.getBoundingClientRect().height;
|
||||
return (snapMinutes() / slotMinutes()) * slotHeightPx;
|
||||
}
|
||||
|
||||
function findMirrorHarness(calendarEl: HTMLElement) {
|
||||
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
|
||||
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
|
||||
return { mirror, harness };
|
||||
}
|
||||
|
||||
function updateMirrorDurationLabel(
|
||||
mirror: HTMLElement,
|
||||
snappedTop: number,
|
||||
snappedEnd: number,
|
||||
snapPx: number
|
||||
) {
|
||||
const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();
|
||||
const durationText = formatDuration(snappedDurationMin * 60);
|
||||
const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child');
|
||||
if (durationEl) {
|
||||
durationEl.textContent = durationText;
|
||||
}
|
||||
}
|
||||
|
||||
function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return;
|
||||
const snapPx = getSnapPixels();
|
||||
if (snapPx <= 0) return;
|
||||
|
||||
const loop = () => {
|
||||
onFrame(calendarEl, snapPx);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
document.body.classList.remove('fc-resizing-active');
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public snap starters ---
|
||||
|
||||
function startSelectSnap() {
|
||||
// Don't start if another snap loop is already running
|
||||
if (rafId !== null) return;
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness || !mirror) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
const snappedTop = Math.round(top / snapPx) * snapPx;
|
||||
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
|
||||
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
|
||||
harness.style.top = snappedTop + 'px';
|
||||
harness.style.bottom = -clampedEnd + 'px';
|
||||
updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);
|
||||
});
|
||||
}
|
||||
|
||||
function startDragSnap() {
|
||||
stop();
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
const height = endPos - top;
|
||||
const snappedTop = Math.round(top / snapPx) * snapPx;
|
||||
harness.style.top = snappedTop + 'px';
|
||||
harness.style.bottom = -(snappedTop + height) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
function startResizeSnap() {
|
||||
stop();
|
||||
document.body.classList.add('fc-resizing-active');
|
||||
|
||||
let initialTop: number | null = null;
|
||||
let initialEnd: number | null = null;
|
||||
let resizeEdge: 'top' | 'bottom' | null = null;
|
||||
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
|
||||
// Detect which edge is being resized
|
||||
if (initialTop === null) {
|
||||
initialTop = top;
|
||||
initialEnd = endPos;
|
||||
} else if (resizeEdge === null) {
|
||||
const topDelta = Math.abs(top - initialTop);
|
||||
const endDelta = Math.abs(endPos - initialEnd!);
|
||||
if (topDelta > 0.5) {
|
||||
resizeEdge = 'top';
|
||||
} else if (endDelta > 0.5) {
|
||||
resizeEdge = 'bottom';
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const snappedEnd = Math.round(endPos / snapPx) * snapPx;
|
||||
const clampedEnd = Math.max(top + snapPx, snappedEnd);
|
||||
harness.style.bottom = -clampedEnd + 'px';
|
||||
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
|
||||
} else if (resizeEdge === 'top') {
|
||||
const snappedTop = Math.round(top / snapPx) * snapPx;
|
||||
const clampedTop = Math.min(endPos - snapPx, snappedTop);
|
||||
harness.style.top = clampedTop + 'px';
|
||||
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pointerdown handler for starting select snap on timegrid background
|
||||
function handleTimegridPointerDown(e: PointerEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.fc-event')) return;
|
||||
startSelectSnap();
|
||||
}
|
||||
|
||||
// Lifecycle: attach/detach pointerdown listener
|
||||
function attachListener() {
|
||||
const calendarEl = getCalendarEl();
|
||||
calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);
|
||||
}
|
||||
|
||||
function detachListener() {
|
||||
const calendarEl = getCalendarEl();
|
||||
calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);
|
||||
}
|
||||
|
||||
onMounted(attachListener);
|
||||
onActivated(attachListener);
|
||||
onDeactivated(() => {
|
||||
stop();
|
||||
detachListener();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
detachListener();
|
||||
});
|
||||
|
||||
return {
|
||||
startSelectSnap,
|
||||
startDragSnap,
|
||||
startResizeSnap,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';
|
||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
} from './field/index';
|
||||
export type { FieldVariants } from './field/index';
|
||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||
export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type {
|
||||
CommandPaletteCommand,
|
||||
CommandPaletteGroup,
|
||||
@@ -92,6 +94,7 @@ export {
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
CalendarSettingsPopover,
|
||||
DateRangePicker,
|
||||
TimezoneMismatchModal,
|
||||
Tooltip,
|
||||
|
||||
Reference in New Issue
Block a user