Skip to main content

max / goingson

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