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 += ` -
| '; // Empty header for hour labels column days.forEach(day => { const isToday = this.isToday(day); html += ` | ${day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} | `; @@ -320,6 +381,7 @@ class Calendar { // Time slots for each day for (let hour = 0; hour < 24; hour++) { html += '
|---|---|
| ${hour.toString().padStart(2, '0')}:00 | `; days.forEach(day => { html += ``;
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} `;
});
}
|