From 992825c291b6e43208d5aaa8ea1036026d92aa7c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Tue, 7 Jul 2026 21:36:50 -0400 Subject: [PATCH] feat: create event with click and drag Signed-off-by: Sebastian Krupinski --- src/components/CalendarView.vue | 2 + src/components/DaysView.vue | 176 +++++++++++++++++++++++++++++--- src/pages/ChronoPage.vue | 1 + src/stores/chronoUiStore.ts | 8 +- 4 files changed, 168 insertions(+), 19 deletions(-) diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index 7eb0057..265a806 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -22,6 +22,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'event-click': [event: EntityObject]; 'date-click': [date: Date]; + 'date-range-select': [range: { start: Date; end: Date }]; 'update:current-date': [date: Date]; 'update:days-span': [span: DaysViewSpan]; 'update:agenda-span': [span: AgendaViewSpan]; @@ -79,6 +80,7 @@ function hidePopup() { :initial-span="initialDaysSpan" @event-click="$emit('event-click', $event)" @date-click="$emit('date-click', $event)" + @date-range-select="$emit('date-range-select', $event)" @event-hover="handleEventHover" @event-hover-end="hidePopup" @update:span="$emit('update:days-span', $event)" diff --git a/src/components/DaysView.vue b/src/components/DaysView.vue index f6eabd3..3748c6e 100644 --- a/src/components/DaysView.vue +++ b/src/components/DaysView.vue @@ -8,7 +8,7 @@ import type { CollectionObject } from '@ChronoManager/models/collection'; import { useChronoInstancesStore } from '@/stores/chronoInstancesStore'; import type { CalendarInstance } from '@/types/instance'; import ViewControls from './ViewControls.vue'; -import { formatEpochTime, formatHour, formatWeekDay } from '@/utils/format'; +import { formatEpochTime, formatHour, formatTime, formatWeekDay } from '@/utils/format'; const ALL_DAY_EVENT_HEIGHT = 24; @@ -21,6 +21,8 @@ const props = defineProps<{ const instancesStore = useChronoInstancesStore(); const selectedSpan = ref(props.initialSpan ?? '7d'); +const hoveredTime = ref<{ day: string; minutes: number } | null>(null); +const timeSelection = ref<{ day: string; anchor: number; current: number } | null>(null); const daysCount = computed(() => spanToDays(selectedSpan.value)); const emit = defineEmits<{ @@ -28,6 +30,7 @@ const emit = defineEmits<{ 'event-hover': [data: { event: MouseEvent; entity: EntityObject }]; 'event-hover-end': []; 'date-click': [date: Date]; + 'date-range-select': [range: { start: Date; end: Date }]; 'update:span': [span: DaysViewSpan]; 'update:current-date': [date: Date]; }>(); @@ -113,22 +116,99 @@ function getEventStyle(instance: CalendarInstance) { }; } -function handleDayClick(event: MouseEvent, day: Date) { +function pointerMinutes(event: MouseEvent, allowEndOfDay = false): number { const target = event.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); - const clickY = event.clientY - rect.top; - - // Calculate the hour based on click position (60px per hour) - const totalMinutes = Math.floor((clickY / 60) * 60); - const hours = Math.floor(totalMinutes / 60); - const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min - - // Create a new date with the clicked time - const clickedDate = new Date(day); - clickedDate.setHours(hours, minutes, 0, 0); - - emit('date-click', clickedDate); + const pointerY = Math.min(Math.max(event.clientY - rect.top, 0), rect.height); + const minutes = Math.round(((pointerY / rect.height) * 1440) / 15) * 15; + + return Math.min(minutes, allowEndOfDay ? 1440 : 1425); } + +function handleHover(event: MouseEvent, day: Date) { + hoveredTime.value = { + day: day.toISOString(), + minutes: pointerMinutes(event), + }; +} + +function formatHoverTime(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const date = new Date(2000, 0, 1, hours, minutes); + return formatTime(date); +} + +function dateAtMinutes(day: Date, totalMinutes: number): Date { + const date = new Date(day); + date.setHours(0, totalMinutes, 0, 0); + return date; +} + +function selectionBounds(selection: NonNullable) { + const start = Math.min(selection.anchor, selection.current); + const end = selection.anchor === selection.current + ? Math.min(start + 15, 1440) + : Math.max(selection.anchor, selection.current); + + return { start, end }; +} + +function handlePointerDown(event: PointerEvent, day: Date) { + if (event.pointerType !== 'mouse' || event.button !== 0) return; + + event.preventDefault(); + const minutes = pointerMinutes(event); + hoveredTime.value = null; + timeSelection.value = { + day: day.toISOString(), + anchor: minutes, + current: minutes, + }; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); +} + +function handlePointerUp(event: PointerEvent, day: Date) { + const selection = timeSelection.value; + if (!selection || selection.day !== day.toISOString()) return; + + selection.current = pointerMinutes(event, true); + const { start, end } = selectionBounds(selection); + timeSelection.value = null; + + if (selection.anchor === selection.current) { + emit('date-click', dateAtMinutes(day, start)); + return; + } + + emit('date-range-select', { + start: dateAtMinutes(day, start), + end: dateAtMinutes(day, end), + }); +} + +function handlePointerMove(event: PointerEvent, day: Date) { + if (timeSelection.value?.day === day.toISOString()) { + timeSelection.value.current = pointerMinutes(event, true); + return; + } + + handleHover(event, day); +} + +function selectionStyle(selection: NonNullable) { + const { start, end } = selectionBounds(selection); + return { + top: `${start / 1440 * 100}%`, + height: `${(end - start) / 1440 * 100}%`, + }; +} + +function selectionLabel(selection: NonNullable): string { + const { start, end } = selectionBounds(selection); + return `${formatHoverTime(start)} – ${formatHoverTime(end)} · ${end - start} min`; +} +