/**
* 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,
};
})();