/** * GoingsOn - Day Planning Render Module * Timeline rendering, unscheduled task rendering, current time indicator */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // ============ Constants ============ const BLOCK_TYPE_LABELS = { free_time: 'Free Time', personal: 'Personal', vacation: 'Vacation', focus: 'Focus', }; // Read --timeline-slot-h from CSS so JS positioning stays in sync with the rule on // .timeline-slot. Falls back to 12 if the variable is unset. function getSlotHeight() { const raw = getComputedStyle(document.documentElement).getPropertyValue('--timeline-slot-h'); const px = parseFloat(raw); return Number.isFinite(px) && px > 0 ? px : 12; } // ============ Timeline Rendering ============ /** * Render the day timeline with 15-minute slots and positioned items. * @param {Date} dayPlanDate - The date being displayed * @param {Object|null} dayPlanData - Day plan data from backend (timelineItems, conflicts) */ function renderTimeline(dayPlanDate, dayPlanData) { const slotsContainer = document.getElementById('timeline-slots'); const itemsContainer = document.getElementById('timeline-items'); // Vacation banner const existingBanner = document.getElementById('vacation-day-banner'); if (existingBanner) existingBanner.remove(); if (dayPlanData?.isVacationDay) { const banner = document.createElement('div'); banner.id = 'vacation-day-banner'; banner.className = 'vacation-day-banner'; banner.textContent = 'Day Off'; slotsContainer.parentElement.insertBefore(banner, slotsContainer); } const slotHeight = getSlotHeight(); const isTouch = !!GoingsOn.touch?.isTouchDevice; // Generate 15-min slots from 12am to 12am (96 slots) let slotsHtml = ''; for (let hour = 0; hour < 24; hour++) { for (let quarter = 0; quarter < 4; quarter++) { const minutes = quarter * 15; const timeStr = `${String(hour).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const slotTime = new Date(dayPlanDate); slotTime.setHours(hour, minutes, 0, 0); const slotTimestamp = slotTime.toISOString(); const isHourStart = quarter === 0; const slotIdx = hour * 4 + quarter; // Desktop: drag-paint via mouse events. Touch: tap via onclick, long-press wired post-render. const paintHandlers = isTouch ? '' : ` onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${slotIdx}, '${escAttr(slotTimestamp)}')" onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${slotIdx}, '${escAttr(slotTimestamp)}')"`; slotsHtml += `
${isHourStart ? timeStr : ''}
`; } } slotsContainer.innerHTML = slotsHtml; // Render timeline items if (!dayPlanData) return; const conflictIds = new Set(); dayPlanData.conflicts.forEach(c => { conflictIds.add(c.item1Id); conflictIds.add(c.item2Id); }); let itemsHtml = ''; dayPlanData.timelineItems.forEach(item => { const startTime = new Date(item.startTime); const startHour = startTime.getHours(); const startMinute = startTime.getMinutes(); // Calculate position using 15-min slots const startSlotIndex = startHour * 4 + Math.floor(startMinute / 15); const topOffset = startSlotIndex * slotHeight + (startMinute % 15) / 15 * slotHeight; // Calculate height based on duration const duration = item.duration || 30; const height = (duration / 15) * slotHeight; const hasConflict = conflictIds.has(item.id); const keyboardHint = item.itemType === 'task' ? ' (up/down to move, Del to unschedule)' : ''; const blockClass = item.blockType ? `block-${item.blockType}` : ''; const blockLabel = item.blockType ? BLOCK_TYPE_LABELS[item.blockType] || item.blockType : ''; const metaText = item.itemType === 'block' ? blockLabel : [item.projectName, item.priority].filter(Boolean).join(' - '); // Touch: tap opens, long-press opens action sheet (wired post-render). No mouse drag. const dragHandler = isTouch ? '' : ` onmousedown="GoingsOn.dayPlan.onItemDragStart(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"`; const titleHint = isTouch ? '' : ' (drag to reschedule)'; itemsHtml += `
${esc(item.title)}
${esc(metaText)}
`; }); itemsContainer.innerHTML = itemsHtml; } /** * Render an unscheduled task item for the sidebar list. * @param {Object} task - Task object with id, description, priority, projectName * @returns {string} HTML string for the task item */ function renderUnscheduledTaskItem(task) { return `
${esc(task.description)}
${task.projectName ? esc(task.projectName) + ' - ' : ''}${task.priority}
`; } /** * Position the current-time indicator line and optionally scroll to it. * @param {Date} dayPlanDate - The date being displayed * @param {Function} formatDateForApi - (Date) => string formatter * @param {boolean} scrollToTime - true to scroll the timeline to current time */ function updateCurrentTimeIndicator(dayPlanDate, formatDateForApi, scrollToTime) { const indicator = document.getElementById('timeline-current-time'); const timelineContainer = document.getElementById('timeline-container'); const now = new Date(); const todayStr = formatDateForApi(new Date()); const selectedStr = formatDateForApi(dayPlanDate); const isToday = todayStr === selectedStr; const slotHeight = getSlotHeight(); if (!isToday) { indicator.style.display = 'none'; if (scrollToTime && timelineContainer) { const targetHour = 9; const topOffset = targetHour * 4 * slotHeight; const scrollTarget = Math.max(0, topOffset - timelineContainer.clientHeight / 3); timelineContainer.scrollTop = scrollTarget; } return; } const hour = now.getHours(); const minute = now.getMinutes(); indicator.style.display = 'block'; const slotIndex = hour * 4 + Math.floor(minute / 15); const topOffset = slotIndex * slotHeight + (minute % 15) / 15 * slotHeight; indicator.style.top = `${topOffset}px`; if (scrollToTime && timelineContainer) { const scrollTarget = Math.max(0, topOffset - timelineContainer.clientHeight / 3); timelineContainer.scrollTop = scrollTarget; } } // ============ Populate GoingsOn.dayPlanRender Namespace ============ GoingsOn.dayPlanRender = { BLOCK_TYPE_LABELS, renderTimeline, renderUnscheduledTaskItem, updateCurrentTimeIndicator, getSlotHeight, }; })();