Skip to main content

max / goingson

9.7 KB · 215 lines History Blame Raw
1 /**
2 * GoingsOn - Day Planning Render Module
3 * Timeline rendering, unscheduled task rendering, current time indicator
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 // ============ Constants ============
12
13 const BLOCK_TYPE_LABELS = {
14 free_time: 'Free Time',
15 personal: 'Personal',
16 vacation: 'Vacation',
17 focus: 'Focus',
18 };
19
20 // Read --timeline-slot-h from CSS so JS positioning stays in sync with the rule on
21 // .timeline-slot. Falls back to 12 if the variable is unset.
22 function getSlotHeight() {
23 const raw = getComputedStyle(document.documentElement).getPropertyValue('--timeline-slot-h');
24 const px = parseFloat(raw);
25 return Number.isFinite(px) && px > 0 ? px : 12;
26 }
27
28 // ============ Timeline Rendering ============
29
30 /**
31 * Render the day timeline with 15-minute slots and positioned items.
32 * @param {Date} dayPlanDate - The date being displayed
33 * @param {Object|null} dayPlanData - Day plan data from backend (timelineItems, conflicts)
34 */
35 function renderTimeline(dayPlanDate, dayPlanData) {
36 const slotsContainer = document.getElementById('timeline-slots');
37 const itemsContainer = document.getElementById('timeline-items');
38
39 // Vacation banner
40 const existingBanner = document.getElementById('vacation-day-banner');
41 if (existingBanner) existingBanner.remove();
42 if (dayPlanData?.isVacationDay) {
43 const banner = document.createElement('div');
44 banner.id = 'vacation-day-banner';
45 banner.className = 'vacation-day-banner';
46 banner.textContent = 'Day Off';
47 slotsContainer.parentElement.insertBefore(banner, slotsContainer);
48 }
49
50 const slotHeight = getSlotHeight();
51 const isTouch = !!GoingsOn.touch?.isTouchDevice;
52
53 // Generate 15-min slots from 12am to 12am (96 slots)
54 let slotsHtml = '';
55 for (let hour = 0; hour < 24; hour++) {
56 for (let quarter = 0; quarter < 4; quarter++) {
57 const minutes = quarter * 15;
58 const timeStr = `${String(hour).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
59 const slotTime = new Date(dayPlanDate);
60 slotTime.setHours(hour, minutes, 0, 0);
61 const slotTimestamp = slotTime.toISOString();
62 const isHourStart = quarter === 0;
63 const slotIdx = hour * 4 + quarter;
64
65 // Desktop: drag-paint via mouse events. Touch: tap via onclick, long-press wired post-render.
66 const paintHandlers = isTouch
67 ? ''
68 : ` onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${slotIdx}, '${escAttr(slotTimestamp)}')"
69 onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${slotIdx}, '${escAttr(slotTimestamp)}')"`;
70
71 slotsHtml += `
72 <div class="timeline-slot${isHourStart ? ' hour-start' : ''}"
73 data-time="${escAttr(slotTimestamp)}"
74 data-hour="${hour}"
75 data-slot-index="${slotIdx}"${paintHandlers}
76 onclick="GoingsOn.dayPlan.onSlotTap(event, ${slotIdx}, '${escAttr(slotTimestamp)}')">
77 <div class="timeline-time">${isHourStart ? timeStr : ''}</div>
78 <div class="timeline-slot-area"></div>
79 </div>
80 `;
81 }
82 }
83 slotsContainer.innerHTML = slotsHtml;
84
85 // Render timeline items
86 if (!dayPlanData) return;
87
88 const conflictIds = new Set();
89 dayPlanData.conflicts.forEach(c => {
90 conflictIds.add(c.item1Id);
91 conflictIds.add(c.item2Id);
92 });
93
94 let itemsHtml = '';
95 dayPlanData.timelineItems.forEach(item => {
96 const startTime = new Date(item.startTime);
97 const startHour = startTime.getHours();
98 const startMinute = startTime.getMinutes();
99
100 // Calculate position using 15-min slots
101 const startSlotIndex = startHour * 4 + Math.floor(startMinute / 15);
102 const topOffset = startSlotIndex * slotHeight + (startMinute % 15) / 15 * slotHeight;
103
104 // Calculate height based on duration
105 const duration = item.duration || 30;
106 const height = (duration / 15) * slotHeight;
107
108 const hasConflict = conflictIds.has(item.id);
109
110 const keyboardHint = item.itemType === 'task' ? ' (up/down to move, Del to unschedule)' : '';
111 const blockClass = item.blockType ? `block-${item.blockType}` : '';
112 const blockLabel = item.blockType ? BLOCK_TYPE_LABELS[item.blockType] || item.blockType : '';
113 const metaText = item.itemType === 'block'
114 ? blockLabel
115 : [item.projectName, item.priority].filter(Boolean).join(' - ');
116 // Touch: tap opens, long-press opens action sheet (wired post-render). No mouse drag.
117 const dragHandler = isTouch
118 ? ''
119 : ` onmousedown="GoingsOn.dayPlan.onItemDragStart(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"`;
120 const titleHint = isTouch ? '' : ' (drag to reschedule)';
121 itemsHtml += `
122 <div class="timeline-item ${item.itemType} ${blockClass} ${hasConflict ? 'conflict' : ''}"
123 style="top: ${topOffset}px; height: ${height}px;"
124 data-id="${escAttr(item.id)}"
125 data-type="${escAttr(item.itemType)}"
126 data-duration="${duration}"
127 onclick="GoingsOn.dayPlan.openTimelineItem('${escAttr(item.id)}', '${escAttr(item.itemType)}')"${dragHandler}
128 onkeydown="GoingsOn.dayPlan.handleTimelineItemKeydown(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"
129 title="${esc(item.title)}${titleHint}${keyboardHint}"
130 tabindex="0" role="button" aria-label="${esc(item.title)}${keyboardHint}">
131 <div class="timeline-item-title">${esc(item.title)}</div>
132 <div class="timeline-item-meta">${esc(metaText)}</div>
133 </div>
134 `;
135 });
136 itemsContainer.innerHTML = itemsHtml;
137 }
138
139 /**
140 * Render an unscheduled task item for the sidebar list.
141 * @param {Object} task - Task object with id, description, priority, projectName
142 * @returns {string} HTML string for the task item
143 */
144 function renderUnscheduledTaskItem(task) {
145 return `
146 <div class="unscheduled-task priority-${task.priority.toLowerCase()}"
147 data-id="${escAttr(task.id)}"
148 onclick="GoingsOn.tasks.openSubtasks('${escAttr(task.id)}')"
149 onkeydown="GoingsOn.dayPlan.handleUnscheduledTaskKeydown(event, '${escAttr(task.id)}')"
150 tabindex="0" role="listitem" aria-label="Unscheduled task: ${esc(task.description)} (Press S to schedule)">
151 <div class="unscheduled-task-title">${esc(task.description)}</div>
152 <div class="unscheduled-task-meta">
153 ${task.projectName ? esc(task.projectName) + ' - ' : ''}${task.priority}
154 </div>
155 <div class="unscheduled-task-actions" onclick="event.stopPropagation()">
156 <button class="btn btn-sm btn-ghost" onclick="GoingsOn.timeTracking.startTimer('${escAttr(task.id)}')" title="Track Time">Track</button>
157 <button class="btn btn-sm btn-ghost" onclick="GoingsOn.focusTimer.start('${escAttr(task.id)}')" title="Focus Mode">Focus</button>
158 </div>
159 </div>
160 `;
161 }
162
163 /**
164 * Position the current-time indicator line and optionally scroll to it.
165 * @param {Date} dayPlanDate - The date being displayed
166 * @param {Function} formatDateForApi - (Date) => string formatter
167 * @param {boolean} scrollToTime - true to scroll the timeline to current time
168 */
169 function updateCurrentTimeIndicator(dayPlanDate, formatDateForApi, scrollToTime) {
170 const indicator = document.getElementById('timeline-current-time');
171 const timelineContainer = document.getElementById('timeline-container');
172 const now = new Date();
173 const todayStr = formatDateForApi(new Date());
174 const selectedStr = formatDateForApi(dayPlanDate);
175 const isToday = todayStr === selectedStr;
176
177 const slotHeight = getSlotHeight();
178
179 if (!isToday) {
180 indicator.style.display = 'none';
181 if (scrollToTime && timelineContainer) {
182 const targetHour = 9;
183 const topOffset = targetHour * 4 * slotHeight;
184 const scrollTarget = Math.max(0, topOffset - timelineContainer.clientHeight / 3);
185 timelineContainer.scrollTop = scrollTarget;
186 }
187 return;
188 }
189
190 const hour = now.getHours();
191 const minute = now.getMinutes();
192
193 indicator.style.display = 'block';
194 const slotIndex = hour * 4 + Math.floor(minute / 15);
195 const topOffset = slotIndex * slotHeight + (minute % 15) / 15 * slotHeight;
196 indicator.style.top = `${topOffset}px`;
197
198 if (scrollToTime && timelineContainer) {
199 const scrollTarget = Math.max(0, topOffset - timelineContainer.clientHeight / 3);
200 timelineContainer.scrollTop = scrollTarget;
201 }
202 }
203
204 // ============ Populate GoingsOn.dayPlanRender Namespace ============
205
206 GoingsOn.dayPlanRender = {
207 BLOCK_TYPE_LABELS,
208 renderTimeline,
209 renderUnscheduledTaskItem,
210 updateCurrentTimeIndicator,
211 getSlotHeight,
212 };
213
214 })();
215