diff --git a/frontend/components/TimeEntryBlock.vue b/frontend/components/TimeEntryBlock.vue index 03be585..8579f0d 100644 --- a/frontend/components/TimeEntryBlock.vue +++ b/frontend/components/TimeEntryBlock.vue @@ -1,33 +1,62 @@ @@ -38,13 +67,16 @@ const props = defineProps<{ entry: TimeEntry hourHeight: number dayStartHour: number + isDragSource?: boolean + columnIndex?: number + totalColumns?: number }>() const emit = defineEmits<{ (e: 'click', entry: TimeEntry): void (e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void - (e: 'resize', entry: TimeEntry, newStoppedAt: string): void - (e: 'move', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void + (e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void + (e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void }>() const blockEl = ref(null) @@ -52,61 +84,155 @@ const blockEl = ref(null) const startDate = computed(() => new Date(props.entry.startedAt)) const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date()) +const resizeTopDeltaMinutes = ref(0) +const resizeBottomDeltaMinutes = ref(0) + const duration = computed(() => { - const diff = endDate.value.getTime() - startDate.value.getTime() + const startMs = startDate.value.getTime() + resizeTopDeltaMinutes.value * 60000 + const endMs = endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000 + const diff = endMs - startMs const h = Math.floor(diff / 3600000) const m = Math.floor((diff % 3600000) / 60000) const s = Math.floor((diff % 60000) / 1000) return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ') }) +const heightPx = computed(() => { + const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value + const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value + return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20) +}) + +// Responsive content levels based on block height +// 3 = full (title + project + types + duration) +// 2 = medium (title + duration) +// 1 = small (title only) +// 0 = tiny (colored bar only) +const sizeLevel = computed(() => { + const h = heightPx.value + if (h >= 50) return 3 + if (h >= 35) return 2 + if (h >= 20) return 1 + return 0 +}) + const blockStyle = computed(() => { - const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() - const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight - const heightPx = Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20) const bgColor = props.entry.project?.color ?? '#94a3b8' + const col = props.columnIndex ?? 0 + const total = props.totalColumns ?? 1 + const gapPx = 2 + const leftPercent = (col / total) * 100 + const widthPercent = (1 / total) * 100 + return { top: `${topPx}px`, - height: `${heightPx}px`, + height: `${heightPx.value}px`, backgroundColor: bgColor, + left: `calc(${leftPercent}% + ${gapPx}px)`, + width: `calc(${widthPercent}% - ${gapPx * 2}px)`, } }) -function onDragStart(event: DragEvent) { - event.dataTransfer!.effectAllowed = 'move' - event.dataTransfer!.setData('application/time-entry-id', String(props.entry.id)) - event.dataTransfer!.setData('application/time-entry-start', props.entry.startedAt) - event.dataTransfer!.setData('application/time-entry-stop', props.entry.stoppedAt ?? '') - ;(event.target as HTMLElement).classList.add('opacity-50') +// --- Click / Drag detection --- +let mouseDownPos = { x: 0, y: 0 } +let mouseDownHandled = false + +function onMouseDown(event: MouseEvent) { + if (event.button !== 0) return + if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return + + mouseDownPos = { x: event.clientX, y: event.clientY } + mouseDownHandled = false + + document.addEventListener('mousemove', onMouseMoveDetect) + document.addEventListener('mouseup', onMouseUpDetect) } -function onDragEnd(event: DragEvent) { - ;(event.target as HTMLElement).classList.remove('opacity-50') +function onMouseMoveDetect(event: MouseEvent) { + const dx = event.clientX - mouseDownPos.x + const dy = event.clientY - mouseDownPos.y + if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) { + mouseDownHandled = true + document.removeEventListener('mousemove', onMouseMoveDetect) + document.removeEventListener('mouseup', onMouseUpDetect) + + const rect = blockEl.value!.getBoundingClientRect() + emit('moveStart', { + entry: props.entry, + offsetY: mouseDownPos.y - rect.top, + }) + } } -function onResizeStart(event: MouseEvent) { +function onMouseUpDetect() { + document.removeEventListener('mousemove', onMouseMoveDetect) + document.removeEventListener('mouseup', onMouseUpDetect) + if (!mouseDownHandled) { + emit('click', props.entry) + } +} + +// --- Resize bottom (change stoppedAt) --- +function onResizeBottomStart(event: MouseEvent) { const startY = event.clientY - const originalHeight = blockEl.value?.offsetHeight ?? 0 + resizeBottomDeltaMinutes.value = 0 + + document.body.style.userSelect = 'none' + document.body.style.cursor = 's-resize' function onMouseMove(e: MouseEvent) { const delta = e.clientY - startY - const newHeight = Math.max(originalHeight + delta, 20) - if (blockEl.value) { - blockEl.value.style.height = `${newHeight}px` + resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15 + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + + const finalDelta = resizeBottomDeltaMinutes.value + resizeBottomDeltaMinutes.value = 0 + + if (finalDelta !== 0) { + const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000) + emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString()) } } - function onMouseUp(e: MouseEvent) { + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) +} + +// --- Resize top (change startedAt) --- +function onResizeTopStart(event: MouseEvent) { + const startY = event.clientY + resizeTopDeltaMinutes.value = 0 + + document.body.style.userSelect = 'none' + document.body.style.cursor = 'n-resize' + + function onMouseMove(e: MouseEvent) { + const delta = e.clientY - startY + resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15 + } + + function onMouseUp() { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' - const delta = e.clientY - startY - const deltaMinutes = Math.round((delta / props.hourHeight) * 60 / 15) * 15 - const originalEnd = endDate.value - const newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000) - emit('resize', props.entry, newEnd.toISOString()) + const finalDelta = resizeTopDeltaMinutes.value + resizeTopDeltaMinutes.value = 0 + + if (finalDelta !== 0) { + const newStart = new Date(startDate.value.getTime() + finalDelta * 60000) + emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString()) + } } document.addEventListener('mousemove', onMouseMove) diff --git a/frontend/components/TimeTrackingCalendar.vue b/frontend/components/TimeTrackingCalendar.vue index 18f32a7..150ea9c 100644 --- a/frontend/components/TimeTrackingCalendar.vue +++ b/frontend/components/TimeTrackingCalendar.vue @@ -1,5 +1,5 @@