Files
Lesstime/frontend/components/time-tracking/TimeEntryBlock.vue
Matthieu ec35a1b2aa
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
feat(ui) : improve time-tracking UX, responsive tags, and task priority flag
- Add duplicate button in time entry drawer
- Make time entry blocks and list responsive (tags wrap, hide on narrow)
- Replace date filter input with calendar icon next to month title
- Fix scroll to current hour in calendar (use gridBodyEl)
- Show project color on ticket code in task cards and my-tasks
- Add red flag icon for high priority tasks in kanban and my-tasks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:44:36 +01:00

270 lines
10 KiB
Vue

<template>
<div
ref="blockEl"
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
:style="blockStyle"
:class="{ 'opacity-40': isDragSource }"
@contextmenu.prevent="emit('contextmenu', $event, entry)"
@mousedown="onMouseDown"
@click.stop
>
<!-- Resize handle top (outside block) -->
<div
class="absolute left-0 right-0 h-3 cursor-n-resize group"
style="bottom: 100%"
@mousedown.stop.prevent="onResizeTopStart"
>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
<!-- Top: title + project -->
<div class="min-w-0">
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Bottom: tags left, duration right -->
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
<span
v-for="tag in visibleTags"
:key="tag.id"
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
<span
v-if="hiddenTagCount > 0"
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
>
+{{ hiddenTagCount }}
</span>
</div>
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
</div>
</div>
<!-- Resize handle bottom (outside block) -->
<div
class="absolute left-0 right-0 h-3 cursor-s-resize group"
style="top: 100%"
@mousedown.stop.prevent="onResizeBottomStart"
>
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
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, newStartedAt: string, newStoppedAt: string): void
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
}>()
const blockEl = ref<HTMLElement | null>(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 mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
})
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 showTags = computed(() => (props.totalColumns ?? 1) <= 2)
const maxVisibleTags = computed(() => {
const total = props.totalColumns ?? 1
if (total >= 2) return 1
return 2
})
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
const hasProject = computed(() => !!props.entry.project)
const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const col = props.columnIndex ?? 0
const total = props.totalColumns ?? 1
const gapPx = 2
const leftPercent = (col / total) * 100
const widthPercent = (1 / total) * 100
const base: Record<string, string> = {
top: `${topPx}px`,
height: `${heightPx.value}px`,
left: `calc(${leftPercent}% + ${gapPx}px)`,
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
}
if (hasProject.value) {
const hex = props.entry.project!.color.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
base.color = `rgb(${r}, ${g}, ${b})`
} else {
base.backgroundColor = '#e5e7eb'
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
base.backgroundSize = '12px 12px'
base.color = '#6b7280'
}
return base
})
// --- 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 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 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
resizeBottomDeltaMinutes.value = 0
document.body.style.userSelect = 'none'
document.body.style.cursor = 's-resize'
function onMouseMove(e: MouseEvent) {
const delta = e.clientY - startY
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())
}
}
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 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)
document.addEventListener('mouseup', onMouseUp)
}
</script>