Files
Lesstime/frontend/components/time-tracking/TimeTrackingCalendar.vue
Matthieu 8544babf8c refactor(i18n) : replace hardcoded French strings with i18n keys
Replace 30+ hardcoded strings across 15 components with $t() calls.
Added keys for common actions, drawers titles, empty states, and modals.

Ticket: T-020

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

581 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
<!-- Day headers -->
<div
class="z-20 flex flex-shrink-0 border-b border-neutral-200 bg-white rounded-t-lg"
>
<div class="w-16 shrink-0 border-r border-neutral-200" />
<div
v-for="day in days"
:key="day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center"
>
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div>
</div>
<!-- Grid body -->
<div ref="gridBodyEl" class="relative flex min-h-0 flex-1 overflow-y-auto">
<!-- Hour labels -->
<div class="w-16 shrink-0">
<div
v-for="hour in hours"
:key="hour"
class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
:style="{ height: `${hourHeight}px` }"
>
{{ String(hour).padStart(2, '0') }} : 00
</div>
</div>
<!-- Day columns -->
<div
v-for="(day, dayIndex) in days"
:key="day.dateStr"
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
class="relative flex-1 border-r border-neutral-100"
@click="onClickGrid($event, day)"
@contextmenu.prevent="onContextMenuGrid($event, day)"
>
<!-- Hour row lines -->
<div
v-for="hour in hours"
:key="hour"
class="border-b border-neutral-100"
:style="{ height: `${hourHeight}px` }"
/>
<!-- Time entry blocks with overlap columns -->
<TimeEntryBlock
v-for="layout in layoutForDay(day.dateStr)"
:key="layout.entry.id"
:entry="layout.entry"
:hour-height="hourHeight"
:day-start-hour="0"
:is-drag-source="dragState?.entryId === layout.entry.id"
:column-index="layout.columnIndex"
:total-columns="layout.totalColumns"
@click="emit('editEntry', $event)"
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
@resize="(ent, newStart, newStop) => emit('resizeEntry', ent, newStart, newStop)"
@move-start="(payload) => onMoveStart(payload, dayIndex)"
/>
<!-- Overflow indicators for dense groups -->
<div
v-for="overflow in overflowsForDay(day.dateStr)"
:key="`overflow-${overflow.topPx}`"
class="absolute right-1 z-20 rounded bg-neutral-700 px-1.5 py-0.5 text-[10px] font-semibold text-white cursor-pointer hover:bg-neutral-600 transition"
:style="{ top: `${overflow.topPx}px` }"
@click.stop="openOverflowPopover(dayIndex, overflow)"
>
+{{ overflow.count }}
</div>
<!-- Overflow popover -->
<div
v-if="overflowPopover && overflowPopover.dayIndex === dayIndex"
class="absolute z-30 w-48 rounded-lg border border-neutral-200 bg-white p-2 shadow-xl"
:style="{ top: `${overflowPopover.topPx}px`, right: '4px' }"
>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-semibold text-neutral-600">{{ overflowPopover.entries.length }} entrées masquées</span>
<button class="text-neutral-400 hover:text-neutral-600 text-xs" @click="overflowPopover = null">&times;</button>
</div>
<div
v-for="entry in overflowPopover.entries"
:key="entry.id"
class="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-neutral-50 transition"
@click.stop="emit('editEntry', entry); overflowPopover = null"
>
<div
class="h-3 w-3 shrink-0 rounded-sm"
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/>
<div class="min-w-0">
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
</div>
</div>
</div>
<!-- Current time indicator -->
<div
v-if="isToday(day.date)"
class="absolute left-0 right-0 z-10 pointer-events-none"
:style="{ top: `${currentTimeTopPx}px` }"
>
<div class="relative flex items-center">
<div class="absolute -left-[5px] h-[10px] w-[10px] rounded-full bg-orange-500" />
<div class="h-[2px] w-full bg-orange-500" />
</div>
</div>
<!-- Drag ghost preview -->
<div
v-if="dragState && dragState.targetDayIndex === dayIndex"
class="absolute left-1 right-1 rounded-md px-2 py-1 text-xs text-white shadow-lg pointer-events-none ring-2 ring-white/60 transition-[top] duration-75"
:style="{
top: `${dragState.ghostTopPx}px`,
height: `${dragState.ghostHeightPx}px`,
backgroundColor: dragState.color,
opacity: 0.75,
}"
>
<div class="font-semibold truncate">{{ dragState.title }}</div>
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
viewMode: 'week' | 'day'
stickyOffset?: number
}>()
const emit = defineEmits<{
(e: 'editEntry', entry: TimeEntry): void
(e: 'createEntry', startedAt: string): void
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'resizeEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
}>()
const hourHeight = 60
const hours = Array.from({ length: 24 }, (_, i) => i)
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
const calendarEl = ref<HTMLElement | null>(null)
const gridBodyEl = ref<HTMLElement | null>(null)
const dayColumnEls = ref<HTMLElement[]>([])
const stickyOffset = computed(() => props.stickyOffset ?? 0)
// --- Current time indicator ---
const nowMinutes = ref(0)
let nowTimer: ReturnType<typeof setInterval> | undefined
function updateNowMinutes() {
const now = new Date()
nowMinutes.value = now.getHours() * 60 + now.getMinutes()
}
const currentTimeTopPx = computed(() => (nowMinutes.value / 60) * hourHeight)
updateNowMinutes()
onMounted(() => {
nowTimer = setInterval(updateNowMinutes, 60_000)
})
onUnmounted(() => {
clearInterval(nowTimer)
})
function getScrollParent(): HTMLElement | null {
let el = calendarEl.value?.parentElement
while (el) {
if (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'visible') return el
el = el.parentElement
}
return null
}
// Scroll to current hour on mount
onMounted(() => {
nextTick(() => {
if (!calendarEl.value) return
const scrollParent = getScrollParent()
if (!scrollParent) return
const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const calendarTop = calendarEl.value.offsetTop
const scrollTarget = calendarTop + (currentMinutes / 60) * hourHeight - scrollParent.clientHeight / 3
scrollParent.scrollTop = Math.max(0, scrollTarget)
})
})
// --- Days computation ---
const days = computed(() => {
const count = props.viewMode === 'week' ? 7 : 1
const result = []
for (let i = 0; i < count; i++) {
const d = new Date(props.startDate)
d.setDate(d.getDate() + i)
const dateStr = toDateStr(d)
const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
const totalMs = dayEntries.reduce((sum, e) => {
if (!e.stoppedAt) return sum
return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
}, 0)
const totalH = Math.floor(totalMs / 3600000)
const totalM = Math.floor((totalMs % 3600000) / 60000)
const totalS = Math.floor((totalMs % 60000) / 1000)
result.push({
date: new Date(d),
dateStr,
dayNum: d.getDate(),
label: dayLabels[d.getDay()],
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
})
}
return result
})
function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function isToday(d: Date): boolean {
return toDateStr(d) === toDateStr(new Date())
}
function entriesForDay(dateStr: string): TimeEntry[] {
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
}
// --- Overlap layout computation ---
const MAX_VISIBLE_COLUMNS = 4
interface EntryLayout {
entry: TimeEntry
columnIndex: number
totalColumns: number
}
interface OverflowIndicator {
topPx: number
count: number
hiddenEntries: TimeEntry[]
}
function getEntryMinutes(entry: TimeEntry): { start: number; end: number } {
const s = new Date(entry.startedAt)
const startMin = s.getHours() * 60 + s.getMinutes()
const e = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
const endMin = e.getHours() * 60 + e.getMinutes()
return { start: startMin, end: Math.max(endMin, startMin + 15) }
}
function computeOverlapLayout(dayEntries: TimeEntry[]): { layouts: EntryLayout[]; overflows: OverflowIndicator[] } {
if (dayEntries.length === 0) return { layouts: [], overflows: [] }
// Sort by start time, then by duration (longest first)
const sorted = [...dayEntries].sort((a, b) => {
const aM = getEntryMinutes(a)
const bM = getEntryMinutes(b)
if (aM.start !== bM.start) return aM.start - bM.start
return (bM.end - bM.start) - (aM.end - aM.start)
})
// Group overlapping entries into clusters
const clusters: TimeEntry[][] = []
let currentCluster: TimeEntry[] = []
let clusterEnd = 0
for (const entry of sorted) {
const { start, end } = getEntryMinutes(entry)
if (currentCluster.length === 0 || start < clusterEnd) {
currentCluster.push(entry)
clusterEnd = Math.max(clusterEnd, end)
} else {
clusters.push(currentCluster)
currentCluster = [entry]
clusterEnd = end
}
}
if (currentCluster.length > 0) clusters.push(currentCluster)
const layouts: EntryLayout[] = []
const overflows: OverflowIndicator[] = []
for (const cluster of clusters) {
// Assign columns within this cluster
const colEnds: number[] = []
const clusterAssignments: { entry: TimeEntry; col: number }[] = []
for (const entry of cluster) {
const { start, end } = getEntryMinutes(entry)
// Find first column where this entry fits
let placed = false
for (let c = 0; c < colEnds.length; c++) {
if (colEnds[c]! <= start) {
colEnds[c] = end
clusterAssignments.push({ entry, col: c })
placed = true
break
}
}
if (!placed) {
clusterAssignments.push({ entry, col: colEnds.length })
colEnds.push(end)
}
}
const totalColumns = Math.min(colEnds.length, MAX_VISIBLE_COLUMNS)
let hasOverflow = false
for (const { entry, col } of clusterAssignments) {
if (col < MAX_VISIBLE_COLUMNS) {
layouts.push({
entry,
columnIndex: col,
totalColumns,
})
} else {
hasOverflow = true
}
}
if (hasOverflow) {
const hidden = clusterAssignments.filter((a) => a.col >= MAX_VISIBLE_COLUMNS)
const firstEntry = cluster[0]!
const { start } = getEntryMinutes(firstEntry)
overflows.push({
topPx: (start / 60) * hourHeight,
count: hidden.length,
hiddenEntries: hidden.map((a) => a.entry),
})
}
}
return { layouts, overflows }
}
const layoutCache = computed(() => {
const cache = new Map<string, { layouts: EntryLayout[]; overflows: OverflowIndicator[] }>()
for (const day of days.value) {
const dayEntries = entriesForDay(day.dateStr)
cache.set(day.dateStr, computeOverlapLayout(dayEntries))
}
return cache
})
function layoutForDay(dateStr: string): EntryLayout[] {
return layoutCache.value.get(dateStr)?.layouts ?? []
}
function overflowsForDay(dateStr: string): OverflowIndicator[] {
return layoutCache.value.get(dateStr)?.overflows ?? []
}
// --- Overflow popover ---
interface OverflowPopoverState {
dayIndex: number
topPx: number
entries: TimeEntry[]
}
const overflowPopover = ref<OverflowPopoverState | null>(null)
function openOverflowPopover(dayIndex: number, overflow: OverflowIndicator) {
overflowPopover.value = {
dayIndex,
topPx: overflow.topPx,
entries: overflow.hiddenEntries,
}
}
function formatTime(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function getSnappedMinutesFromY(y: number): number {
return Math.max(0, Math.min(23 * 60 + 45, Math.round((y / hourHeight) * 60 / 15) * 15))
}
function formatMinutes(totalMinutes: number): string {
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
// --- Click to create ---
let dragEndTime = 0
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
// Suppress click right after drag end
if (Date.now() - dragEndTime < 200) return
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const y = event.clientY - rect.top
const minutes = getSnappedMinutesFromY(y)
const h = Math.floor(minutes / 60)
const m = minutes % 60
const d = new Date(day.date)
d.setHours(h, m, 0, 0)
emit('createEntry', d.toISOString())
}
function onContextMenuGrid(event: MouseEvent, _day: { date: Date; dateStr: string }) {
emit('contextmenu', event, null)
}
// --- Drag to move ---
interface DragState {
entryId: number
entry: TimeEntry
title: string
color: string
durationMinutes: number
ghostHeightPx: number
offsetY: number
targetDayIndex: number
ghostTopPx: number
snappedMinutes: number
timeLabel: string
}
const dragState = ref<DragState | null>(null)
let autoScrollActive = false
let lastMouseEvent: MouseEvent | null = null
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
const entry = payload.entry
const startMinutes = new Date(entry.startedAt).getHours() * 60 + new Date(entry.startedAt).getMinutes()
const endMinutes = entry.stoppedAt
? new Date(entry.stoppedAt).getHours() * 60 + new Date(entry.stoppedAt).getMinutes()
: startMinutes + 60
const durationMinutes = endMinutes - startMinutes
dragState.value = {
entryId: entry.id,
entry,
title: entry.title || t('common.untitled'),
color: entry.project?.color ?? '#94a3b8',
durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
offsetY: payload.offsetY,
targetDayIndex: sourceDayIndex,
ghostTopPx: (startMinutes / 60) * hourHeight,
snappedMinutes: startMinutes,
timeLabel: `${formatMinutes(startMinutes)} ${formatMinutes(endMinutes)}`,
}
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function updateDragPosition(event: MouseEvent) {
if (!dragState.value) return
// Find which column the cursor is over
let targetDayIndex = dragState.value.targetDayIndex
for (let i = 0; i < dayColumnEls.value.length; i++) {
const el = dayColumnEls.value[i]
if (!el) continue
const rect = el.getBoundingClientRect()
if (event.clientX >= rect.left && event.clientX <= rect.right) {
targetDayIndex = i
break
}
}
// Calculate Y position in the target column
const targetCol = dayColumnEls.value[targetDayIndex]
if (!targetCol) return
const colRect = targetCol.getBoundingClientRect()
const y = event.clientY - colRect.top - dragState.value.offsetY
const snappedMinutes = getSnappedMinutesFromY(y)
const endMinutes = snappedMinutes + dragState.value.durationMinutes
dragState.value.targetDayIndex = targetDayIndex
dragState.value.snappedMinutes = snappedMinutes
dragState.value.ghostTopPx = (snappedMinutes / 60) * hourHeight
dragState.value.timeLabel = `${formatMinutes(snappedMinutes)} ${formatMinutes(endMinutes)}`
}
function onDragMove(event: MouseEvent) {
if (!dragState.value) return
event.preventDefault()
lastMouseEvent = event
updateDragPosition(event)
// Start auto-scroll if not running
if (!autoScrollActive) {
autoScrollActive = true
requestAnimationFrame(autoScrollLoop)
}
}
function autoScrollLoop() {
const scrollParent = getScrollParent()
if (!autoScrollActive || !lastMouseEvent || !scrollParent || !dragState.value) {
autoScrollActive = false
return
}
const rect = scrollParent.getBoundingClientRect()
const edgeSize = 60
const maxSpeed = 10
const distFromTop = lastMouseEvent.clientY - rect.top
const distFromBottom = rect.bottom - lastMouseEvent.clientY
let scrolled = false
if (distFromTop < edgeSize && distFromTop > 0) {
scrollParent.scrollTop -= maxSpeed * (1 - distFromTop / edgeSize)
scrolled = true
} else if (distFromBottom < edgeSize && distFromBottom > 0) {
scrollParent.scrollTop += maxSpeed * (1 - distFromBottom / edgeSize)
scrolled = true
}
// Update ghost position if we scrolled (scroll changes coordinate mapping)
if (scrolled && lastMouseEvent) {
updateDragPosition(lastMouseEvent)
}
requestAnimationFrame(autoScrollLoop)
}
function onDragEnd() {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
document.body.style.userSelect = ''
document.body.style.cursor = ''
autoScrollActive = false
lastMouseEvent = null
if (!dragState.value) return
const state = dragState.value
const targetDay = days.value[state.targetDayIndex]
if (targetDay) {
const h = Math.floor(state.snappedMinutes / 60)
const m = state.snappedMinutes % 60
const newStart = new Date(targetDay.date)
newStart.setHours(h, m, 0, 0)
const newStop = new Date(newStart.getTime() + state.durationMinutes * 60000)
emit('moveEntry', state.entry, newStart.toISOString(), newStop.toISOString())
}
dragState.value = null
dragEndTime = Date.now()
}
</script>