From da66384700236cb963381d67faeda197435b1845 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 28 Jan 2026 07:32:45 +0100 Subject: [PATCH] fix(calendar): align time entries with timestamps in day view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix issue where time entries were stacking at the top of the day view instead of being positioned according to their start and end times. Changes: - Convert day-events-container from flexbox to absolute positioning - Set container height to 1440px (24 hours × 60px/hour) - Calculate absolute top position based on entry start time - Calculate entry height based on duration (end - start) - Apply positioning to both time entries and calendar events - Handle edge cases: active timers, entries spanning midnight, overlapping entries Fixes #457 --- app/static/calendar.css | 62 ++++++++++++++- app/static/calendar.js | 166 ++++++++++++++++++++++++++++++++-------- 2 files changed, 193 insertions(+), 35 deletions(-) diff --git a/app/static/calendar.css b/app/static/calendar.css index f9258546..132ad385 100644 --- a/app/static/calendar.css +++ b/app/static/calendar.css @@ -28,9 +28,9 @@ } .day-events-container { - display: flex; - flex-direction: column; - gap: 0.5rem; + position: relative; + height: 1440px; /* 24 hours × 60px per hour */ + width: 100%; } .event-card { @@ -43,6 +43,20 @@ transition: all 0.2s ease; } +/* Absolutely positioned event cards for day view */ +.day-events-container .event-card { + position: absolute; + left: 0.25rem; + width: calc(100% - 0.5rem); + min-height: 30px; + z-index: 1; + overflow: hidden; +} + +.day-events-container .event-card:hover { + z-index: 2; +} + .event-card:hover { box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); transform: translateY(-1px); @@ -139,6 +153,13 @@ text-align: center; } +.week-table th.hour-label-header { + width: 60px; + min-width: 60px; + padding: 0.5rem; + background-color: var(--header-bg, #f9fafb); +} + .week-table th.today { background-color: #dbeafe; color: #1e40af; @@ -153,12 +174,32 @@ color: #93c5fd; } +.hour-label-cell { + width: 60px; + min-width: 60px; + height: 60px; + padding: 0.5rem; + border: 1px solid var(--border-color, #e2e8f0); + background-color: var(--header-bg, #f9fafb); + text-align: center; + font-size: 0.75rem; + color: var(--text-muted, #6b7280); + font-weight: 500; + vertical-align: middle; +} + +.dark .hour-label-cell { + background-color: var(--header-dark-bg, #1e293b); + color: var(--text-muted, #9ca3af); +} + .week-cell { height: 60px; border: 1px solid var(--border-color, #e2e8f0); padding: 0.25rem; vertical-align: top; position: relative; + overflow: visible; } .week-cell:hover { @@ -190,7 +231,20 @@ background-color: #10b981 !important; cursor: default !important; opacity: 0.8 !important; - pointer-events: none; + pointer-events: auto; + left: 2px; + right: 2px; + margin: 0; + padding: 0.125rem 0.25rem; + display: flex; + align-items: center; + box-sizing: border-box; + min-height: 2px; + font-size: 0.7rem; + line-height: 1.2; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .event-chip.task-chip { diff --git a/app/static/calendar.js b/app/static/calendar.js index cca27330..982c40d3 100644 --- a/app/static/calendar.js +++ b/app/static/calendar.js @@ -108,14 +108,29 @@ class Calendar { const response = await fetch(url); const data = await response.json(); - // Parse items by type (all items come in the 'events' array with item_type in extendedProps) - const allItems = data.events || []; - this.events = allItems.filter(item => item.extendedProps?.item_type === 'event'); - this.tasks = allItems.filter(item => item.extendedProps?.item_type === 'task'); - this.timeEntries = allItems.filter(item => item.extendedProps?.item_type === 'time_entry'); + // Build unified lists from calendar API (events, tasks, time_entries are separate). + // Map to common shape with start/end and extendedProps.item_type for render. + const rawEvents = data.events || []; + const rawTasks = data.tasks || []; + const rawTimeEntries = data.time_entries || []; + this.events = rawEvents.map(e => ({ + ...e, + extendedProps: { ...e, item_type: 'event' } + })); + this.tasks = rawTasks.map(t => ({ + id: t.id, + title: t.title, + start: t.dueDate, + end: t.dueDate, + extendedProps: { ...t, item_type: 'task' } + })); + this.timeEntries = rawTimeEntries.map(e => ({ + ...e, + extendedProps: { ...e, item_type: 'time_entry' } + })); console.log('API Response:', { - total: allItems.length, + total: this.events.length + this.tasks.length + this.timeEntries.length, events: this.events.length, tasks: this.tasks.length, time_entries: this.timeEntries.length, @@ -244,12 +259,29 @@ class Calendar { if (this.showEvents) { this.events.forEach(event => { const eventStart = new Date(event.start); - if (eventStart >= dayStart && eventStart <= dayEnd) { - const time = eventStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const eventEnd = event.end ? new Date(event.end) : null; + + // Check if event overlaps with current day + if (eventStart <= dayEnd && (eventEnd ? eventEnd >= dayStart : true)) { + const effectiveStart = eventStart < dayStart ? dayStart : eventStart; + const effectiveEnd = eventEnd ? (eventEnd > dayEnd ? dayEnd : eventEnd) : new Date(effectiveStart.getTime() + 60 * 60 * 1000); // Default 1 hour if no end + + // Calculate position: each hour = 60px, each minute = 1px + const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes(); + const topPosition = startMinutes; + + // Calculate height based on duration + const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60); + // Ensure height is at least 30px and doesn't exceed container (1440px) + const heightMinutes = Math.max(30, Math.min(1440 - topPosition, durationMinutes)); + + const time = effectiveStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); const eventTitle = this.escapeHtml(event.title); const eventColor = event.color || '#3b82f6'; html += ` -
+
${eventTitle}
${time} @@ -279,14 +311,43 @@ class Calendar { if (this.showTimeEntries) { this.timeEntries.forEach(entry => { const entryStart = new Date(entry.start); - if (entryStart >= dayStart && entryStart <= dayEnd) { - const startTime = entryStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const entryEnd = entry.end ? new Date(entry.end) : null; + + // Check if entry overlaps with current day + const entryEndForDay = entryEnd || new Date(entryStart.getTime() + 30 * 60 * 1000); // Default 30 min if no end + const effectiveStart = entryStart < dayStart ? dayStart : entryStart; + const effectiveEnd = entryEndForDay > dayEnd ? dayEnd : entryEndForDay; + + if (effectiveStart <= dayEnd && effectiveEnd >= dayStart) { + // Calculate position: each hour = 60px, each minute = 1px + const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes(); + const topPosition = startMinutes; + + // Calculate height based on duration + let heightMinutes; + if (entryEnd) { + const durationMinutes = (effectiveEnd - effectiveStart) / (1000 * 60); + // Ensure height is at least 30px and doesn't exceed container (1440px) + heightMinutes = Math.max(30, Math.min(1440 - topPosition, durationMinutes)); + } else { + // Active timer without end time - show as minimum height with indicator + // But don't exceed container bounds + heightMinutes = Math.min(30, 1440 - topPosition); + } + + const startTime = effectiveStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const endTimeStr = entryEnd ? effectiveEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : ''; const entryTitle = this.escapeHtml(entry.title); - const notes = entry.notes ? `
${this.escapeHtml(entry.notes)}` : ''; + // Check both entry.notes and entry.extendedProps.notes for compatibility + const notesText = entry.notes || entry.extendedProps?.notes || ''; + const notes = notesText ? `
${this.escapeHtml(notesText)}` : ''; + const durationText = entryEnd ? `${startTime} - ${endTimeStr}` : `${startTime} (active)`; + html += ` -
+
${entryTitle} -
${startTime} +
${durationText} ${notes}
`; @@ -309,7 +370,7 @@ class Calendar { } let html = '
'; - + html += ''; // Empty header for hour labels column days.forEach(day => { const isToday = this.isToday(day); html += ``; @@ -320,6 +381,7 @@ class Calendar { // Time slots for each day for (let hour = 0; hour < 24; hour++) { html += ''; + html += ``; days.forEach(day => { html += `
${day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
${hour.toString().padStart(2, '0')}:00`; html += this.renderWeekCellEvents(day, hour); @@ -332,6 +394,39 @@ class Calendar { this.container.innerHTML = html; } + spanningItemOverlapsCell(item, cellStart, cellEnd) { + const itemStart = new Date(item.start); + const itemEnd = item.end ? new Date(item.end) : null; + if (!itemEnd || itemEnd <= itemStart) { + return itemStart >= cellStart && itemStart < cellEnd; + } + return itemStart < cellEnd && itemEnd > cellStart; + } + + getSpanningItemPositionInCell(item, cellStart, cellEnd) { + const itemStart = new Date(item.start); + const itemEnd = item.end ? new Date(item.end) : null; + if (!itemEnd || itemEnd <= itemStart) { + const minutes = itemStart.getMinutes(); + const seconds = itemStart.getSeconds(); + const topPercent = ((minutes * 60 + seconds) / 3600) * 100; + return { top: topPercent, height: 10 }; + } + const segmentStart = itemStart > cellStart ? itemStart : cellStart; + const segmentEnd = itemEnd < cellEnd ? itemEnd : cellEnd; + const cellDuration = cellEnd - cellStart; + const topOffset = segmentStart - cellStart; + const segmentDuration = segmentEnd - segmentStart; + const topPercent = (topOffset / cellDuration) * 100; + const heightPercent = (segmentDuration / cellDuration) * 100; + const minHeightPercent = Math.max(heightPercent, 4); + const height = Math.min(minHeightPercent, 100 - topPercent); + return { + top: Math.max(0, topPercent), + height: Math.max(4, height) + }; + } + renderWeekCellEvents(day, hour) { const cellStart = new Date(day); cellStart.setHours(hour, 0, 0, 0); @@ -340,15 +435,21 @@ class Calendar { let html = ''; - // Check events + // Check events - span by start/end and align with timestamps if (this.showEvents) { this.events.forEach(event => { + if (event.allDay) return; + if (!this.spanningItemOverlapsCell(event, cellStart, cellEnd)) return; + const position = this.getSpanningItemPositionInCell(event, cellStart, cellEnd); + const eventTitle = this.escapeHtml(event.title); + const eventColor = event.color || '#3b82f6'; const eventStart = new Date(event.start); - if (eventStart >= cellStart && eventStart < cellEnd) { - const eventTitle = this.escapeHtml(event.title); - const eventColor = event.color || '#3b82f6'; - html += `
📅 ${eventTitle}
`; - } + const eventEnd = event.end ? new Date(event.end) : null; + let timeRange = eventStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (eventEnd) timeRange += ' - ' + eventEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const showText = position.height >= 15; + const displayText = showText ? `\uD83D\uDCC5 ${eventTitle}` : '\uD83D\uDCC5'; + html += `
${displayText}
`; }); } @@ -356,22 +457,25 @@ class Calendar { if (this.showTasks) { this.tasks.forEach(task => { const taskDate = new Date(task.start); - // Show task if it's due on this day and hour 9 (morning) - if (taskDate.toDateString() === day.toDateString() && hour === 9) { - const taskTitle = this.escapeHtml(task.title); - html += `
📋 ${taskTitle}
`; - } + if (taskDate.toDateString() !== day.toDateString() || hour !== 9) return; + const taskTitle = this.escapeHtml(task.title); + html += `
\uD83D\uDCCB ${taskTitle}
`; }); } - // Check time entries + // Check time entries - span by start/end and align with timestamps if (this.showTimeEntries) { this.timeEntries.forEach(entry => { + if (!this.spanningItemOverlapsCell(entry, cellStart, cellEnd)) return; + const position = this.getSpanningItemPositionInCell(entry, cellStart, cellEnd); + const entryTitle = this.escapeHtml(entry.title); const entryStart = new Date(entry.start); - if (entryStart >= cellStart && entryStart < cellEnd) { - const entryTitle = this.escapeHtml(entry.title); - html += `
⏱ ${entryTitle}
`; - } + const entryEnd = entry.end ? new Date(entry.end) : null; + let timeRange = entryStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (entryEnd) timeRange += ' - ' + entryEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const showText = position.height >= 15; + const displayText = showText ? `\u23F1 ${entryTitle}` : '\u23F1'; + html += `
${displayText}
`; }); }