Skip to main content

max / goingson

17.9 KB · 485 lines History Blame Raw
1 /**
2 * GoingsOn - Day Planning Module
3 * Core load/render, navigation, mobile, keyboard, cleanup.
4 * Painting lives in day-planning-paint.js.
5 * Schedule modal + daily review live in day-planning-schedule.js.
6 * Timeline rendering lives in day-planning-render.js.
7 */
8
9 (function() {
10 'use strict';
11
12 // ============ Day Planning State ============
13 // dayPlanDate, dayPlanData, currentTimeIndicatorInterval, paintingState
14 // all live on GoingsOn.state (single source of truth)
15
16 let unscheduledTasksScroller = null;
17
18 // ============ Day Planning Functions ============
19
20 const formatDateForApi = GoingsOn.utils.formatDateForApi;
21 const formatDateDisplay = GoingsOn.utils.formatDateDisplay;
22
23 /**
24 * Load day planning data for the current date and render timeline + sidebar.
25 */
26 async function load() {
27 const dateStr = formatDateForApi(GoingsOn.state.dayPlanDate);
28
29 // Update date picker and display
30 document.getElementById('day-plan-date').value = dateStr;
31 document.getElementById('day-plan-date-display').textContent = formatDateDisplay(GoingsOn.state.dayPlanDate);
32
33 try {
34 GoingsOn.state.set('dayPlanData', await GoingsOn.api.dayPlanning.getDay(dateStr));
35 renderTimeline();
36 renderUnscheduledTasks();
37 // Render time summary in sidebar
38 if (GoingsOn.timeSummary) {
39 GoingsOn.timeSummary.render(document.getElementById('time-summary-container'));
40 }
41 // Scroll to current time on initial load
42 updateCurrentTimeIndicator(true);
43
44 // Start current time indicator update (without auto-scroll)
45 if (GoingsOn.state.currentTimeIndicatorInterval) {
46 clearInterval(GoingsOn.state.currentTimeIndicatorInterval);
47 }
48 GoingsOn.state.set('currentTimeIndicatorInterval', setInterval(() => updateCurrentTimeIndicator(false), 60000));
49
50 // Render the inline "Accomplished" list and refresh nudge state
51 GoingsOn.dayPlanSchedule.loadDayReviewPane();
52
53 // Initialize mobile swipe navigation (once per load)
54 if (!swipeNavCleanup) initSwipeDayNav();
55 } catch (err) {
56 console.error('Failed to load day planning:', err);
57 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load day planning'), 'error', {
58 action: { label: 'Retry', fn: load },
59 duration: 8000,
60 });
61 }
62 }
63
64 function renderTimeline() {
65 GoingsOn.dayPlanRender.renderTimeline(GoingsOn.state.dayPlanDate, GoingsOn.state.dayPlanData);
66 wireTouchInteractions();
67 }
68
69 // Per-render touch wiring. Cleanup happens implicitly when the slot/item elements
70 // are replaced on next render.
71 let touchCleanups = [];
72 function wireTouchInteractions() {
73 if (!GoingsOn.touch?.isTouchDevice) return;
74
75 touchCleanups.forEach(fn => fn());
76 touchCleanups = [];
77
78 // Long-press on empty slots opens the picker with a 1-hour default range.
79 // Snap to the nearest 30-min boundary so finger imprecision doesn't land on
80 // a quirky :15/:45 start time.
81 document.querySelectorAll('#timeline-slots .timeline-slot').forEach(slot => {
82 const idx = parseInt(slot.dataset.slotIndex, 10);
83 const snapped = Math.floor(idx / 2) * 2;
84 touchCleanups.push(GoingsOn.touch.addLongPress(slot, () => {
85 if (GoingsOn.state.paintingState) return;
86 const start = GoingsOn.dayPlanPaint.slotToTime(snapped);
87 const end = GoingsOn.dayPlanPaint.slotToTime(snapped + 4); // 1 hour
88 GoingsOn.dayPlanPaint.openPaintedEventModal(start, end);
89 }));
90 });
91
92 // Long-press on timeline items opens an action sheet (Open, Reschedule, Unschedule for tasks).
93 document.querySelectorAll('#timeline-items .timeline-item').forEach(item => {
94 const id = item.dataset.id;
95 const type = item.dataset.type;
96 touchCleanups.push(GoingsOn.touch.addLongPress(item, () => {
97 openItemActionSheet(id, type);
98 }));
99 });
100 }
101
102 function openItemActionSheet(id, type) {
103 const items = [
104 { label: 'Open', action: () => openTimelineItem(id, type) },
105 ];
106 if (type === 'task') {
107 items.push({
108 label: 'Reschedule',
109 action: () => GoingsOn.dayPlanSchedule.openScheduleTaskModal(id),
110 });
111 items.push({
112 label: 'Unschedule',
113 danger: true,
114 action: () => unscheduleTask(id),
115 });
116 } else if (type === 'event' || type === 'block') {
117 items.push({
118 label: 'Edit time',
119 action: () => GoingsOn.events.openEdit(id),
120 });
121 }
122 GoingsOn.ui.showActionSheet(items);
123 }
124
125 function renderUnscheduledTasks() {
126 const container = document.getElementById('unscheduled-tasks');
127
128 if (!GoingsOn.state.dayPlanData || GoingsOn.state.dayPlanData.unscheduledTasks.length === 0) {
129 if (unscheduledTasksScroller) {
130 unscheduledTasksScroller.destroy();
131 unscheduledTasksScroller = null;
132 }
133 container.innerHTML = '<div class="empty-unscheduled">Nothing unscheduled &mdash; enjoy your day.</div>';
134 return;
135 }
136
137 if (!unscheduledTasksScroller) {
138 unscheduledTasksScroller = new GoingsOn.VirtualScroller({
139 container: container,
140 renderItem: GoingsOn.dayPlanRender.renderUnscheduledTaskItem,
141 getItems: () => GoingsOn.state.dayPlanData?.unscheduledTasks || [],
142 rowHeight: { estimated: 60, measure: true },
143 overscan: 3,
144 });
145 } else {
146 unscheduledTasksScroller.refresh();
147 }
148 }
149
150 function updateCurrentTimeIndicator(scrollToTime = false) {
151 GoingsOn.dayPlanRender.updateCurrentTimeIndicator(GoingsOn.state.dayPlanDate, formatDateForApi, scrollToTime);
152 }
153
154 /**
155 * Open a timeline item based on its type (task subtasks or event detail).
156 * @param {string} id - Item ID
157 * @param {string} itemType - 'task', 'event', or 'block'
158 */
159 function openTimelineItem(id, itemType) {
160 if (itemType === 'task') {
161 GoingsOn.tasks.openSubtasks(id);
162 } else if (itemType === 'event' || itemType === 'block') {
163 GoingsOn.events.open(id);
164 }
165 }
166
167 // ============ Date Navigation ============
168
169 function previousDay() {
170 const prev = new Date(GoingsOn.state.dayPlanDate);
171 prev.setDate(prev.getDate() - 1);
172 GoingsOn.state.set('dayPlanDate', prev);
173 load();
174 }
175
176 function nextDay() {
177 const next = new Date(GoingsOn.state.dayPlanDate);
178 next.setDate(next.getDate() + 1);
179 GoingsOn.state.set('dayPlanDate', next);
180 load();
181 }
182
183 function goToToday() {
184 GoingsOn.state.set('dayPlanDate', new Date());
185 load();
186 }
187
188 function onDatePickerChange() {
189 const picker = document.getElementById('day-plan-date');
190 GoingsOn.state.set('dayPlanDate', new Date(picker.value + 'T12:00:00'));
191 load();
192 }
193
194 // ============ Mobile: Tap-to-Create ============
195
196 /**
197 * On touch devices, tapping a time slot opens the create modal
198 * with the selected time pre-filled (30min default duration).
199 */
200 function onSlotTap(event, slotIndex, slotTime) {
201 if (!GoingsOn.touch?.isTouchDevice) return;
202 // Ignore if it was part of a paint drag
203 if (GoingsOn.state.paintingState) return;
204 // Ignore taps on items
205 if (event.target.closest('.timeline-item')) return;
206
207 // Snap to 30-min grid on touch to absorb finger imprecision.
208 const snapped = Math.floor(slotIndex / 2) * 2;
209 const startTime = GoingsOn.dayPlanPaint.slotToTime(snapped);
210 const endTime = GoingsOn.dayPlanPaint.slotToTime(snapped + 2); // 30min = 2 slots
211 GoingsOn.dayPlanPaint.openPaintedEventModal(startTime, endTime);
212 }
213
214 // ============ Mobile: Swipe Day Navigation ============
215
216 let swipeNavCleanup = null;
217
218 function initSwipeDayNav() {
219 if (!GoingsOn.touch?.isTouchDevice) return;
220
221 const container = document.getElementById('timeline-container');
222 if (!container) return;
223
224 swipeNavCleanup = GoingsOn.touch.addSwipeNavigation(container, {
225 onLeft: nextDay,
226 onRight: previousDay,
227 });
228 }
229
230 // ============ Mobile: Collapsible Sidebar ============
231
232 function toggleSidebar() {
233 const sidebar = document.querySelector('.day-plan-sidebar');
234 if (sidebar) sidebar.classList.toggle('collapsed');
235 }
236
237 // ============ View Cleanup ============
238
239 function cleanup() {
240 if (GoingsOn.state.currentTimeIndicatorInterval) {
241 clearInterval(GoingsOn.state.currentTimeIndicatorInterval);
242 GoingsOn.state.set('currentTimeIndicatorInterval', null);
243 }
244
245 if (GoingsOn.state.paintingState) {
246 document.removeEventListener('mouseup', GoingsOn.dayPlanPaint.onPaintEnd);
247 if (GoingsOn.state.paintingState.preview) {
248 GoingsOn.state.paintingState.preview.remove();
249 }
250 GoingsOn.state.set('paintingState', null);
251 const container = document.getElementById('timeline-container');
252 if (container) {
253 container.classList.remove('is-painting');
254 }
255 }
256
257 if (unscheduledTasksScroller) {
258 unscheduledTasksScroller.destroy();
259 unscheduledTasksScroller = null;
260 }
261
262 if (swipeNavCleanup) {
263 swipeNavCleanup();
264 swipeNavCleanup = null;
265 }
266 }
267
268 // ============ Keyboard Accessibility ============
269
270 function handleUnscheduledTaskKeydown(event, taskId) {
271 if (event.key === 'Enter' || event.key === ' ') {
272 event.preventDefault();
273 GoingsOn.tasks.openSubtasks(taskId);
274 } else if (event.key === 's' || event.key === 'S') {
275 event.preventDefault();
276 GoingsOn.dayPlanSchedule.openScheduleTaskModal(taskId);
277 }
278 }
279
280 async function handleTimelineItemKeydown(event, itemId, itemType) {
281 if (event.key === 'Enter' || event.key === ' ') {
282 event.preventDefault();
283 openTimelineItem(itemId, itemType);
284 return;
285 }
286
287 if (itemType !== 'task') return;
288
289 if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
290 event.preventDefault();
291 await moveScheduledTask(itemId, event.key === 'ArrowUp' ? -15 : 15);
292 } else if (event.key === 'Delete' || event.key === 'Backspace') {
293 event.preventDefault();
294 await unscheduleTask(itemId);
295 }
296 }
297
298 /**
299 * Move a scheduled task's start time by a delta (keyboard arrow keys).
300 * @param {string} taskId - Scheduled task ID
301 * @param {number} deltaMinutes - Minutes to shift (+15 or -15)
302 */
303 async function moveScheduledTask(taskId, deltaMinutes) {
304 if (!GoingsOn.state.dayPlanData) return;
305
306 const item = GoingsOn.state.dayPlanData.timelineItems.find(i => i.id === taskId && i.itemType === 'task');
307 if (!item) return;
308
309 const currentStart = new Date(item.startTime);
310 const newStart = new Date(currentStart.getTime() + deltaMinutes * 60 * 1000);
311
312 const hour = newStart.getHours();
313 const minute = newStart.getMinutes();
314 if (hour < 0 || (hour === 23 && minute > 45) || hour > 23) {
315 return;
316 }
317
318 try {
319 await GoingsOn.api.dayPlanning.scheduleTask(taskId, {
320 startTime: newStart.toISOString(),
321 duration: item.duration || 30
322 });
323 await load();
324
325 requestAnimationFrame(() => {
326 const movedItem = document.querySelector(`.timeline-item[data-id="${taskId}"]`);
327 if (movedItem) movedItem.focus();
328 });
329 } catch (err) {
330 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move task'), 'error');
331 }
332 }
333
334 /**
335 * Remove a task from the day's schedule.
336 * @param {string} taskId - Task ID to unschedule
337 */
338 async function unscheduleTask(taskId) {
339 try {
340 await GoingsOn.api.dayPlanning.unscheduleTask(taskId);
341 GoingsOn.ui.showToast('Task unscheduled', 'success');
342 await load();
343 } catch (err) {
344 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to unschedule task'), 'error');
345 }
346 }
347
348 // ============ Drag-to-Reschedule ============
349
350 let dragState = null;
351
352 /**
353 * Start dragging a timeline item to reschedule it.
354 * @param {MouseEvent} event
355 * @param {string} itemId - Item ID
356 * @param {string} itemType - 'task', 'event', or 'block'
357 */
358 function onItemDragStart(event, itemId, itemType) {
359 if (event.button !== 0) return;
360
361 const el = event.currentTarget;
362 const slotsContainer = document.getElementById('timeline-slots');
363 if (!slotsContainer) return;
364
365 const startY = event.clientY;
366 const origTop = parseFloat(el.style.top);
367 const slotHeight = GoingsOn.dayPlanRender.getSlotHeight();
368 let moved = false;
369
370 function onMouseMove(e) {
371 const dy = e.clientY - startY;
372 if (!moved && Math.abs(dy) < 5) return; // dead zone to distinguish click from drag
373 moved = true;
374 el.classList.add('dragging');
375
376 // Snap to 15-min slots
377 const slotDelta = Math.round(dy / slotHeight);
378 const newTop = origTop + slotDelta * slotHeight;
379 el.style.top = `${Math.max(0, newTop)}px`;
380 }
381
382 function onMouseUp(e) {
383 document.removeEventListener('mousemove', onMouseMove);
384 document.removeEventListener('mouseup', onMouseUp);
385 el.classList.remove('dragging');
386
387 if (!moved) return; // was a click, not a drag — let onclick handle it
388
389 // Prevent the click event from firing after drag
390 el.addEventListener('click', function suppress(ev) {
391 ev.stopPropagation();
392 el.removeEventListener('click', suppress, true);
393 }, { capture: true, once: true });
394
395 const dy = e.clientY - startY;
396 const slotDelta = Math.round(dy / slotHeight);
397 if (slotDelta === 0) return;
398
399 const deltaMinutes = slotDelta * 15;
400 rescheduleItem(itemId, itemType, deltaMinutes, parseInt(el.dataset.duration) || 30);
401 }
402
403 document.addEventListener('mousemove', onMouseMove);
404 document.addEventListener('mouseup', onMouseUp);
405 }
406
407 /**
408 * Reschedule a timeline item by a time delta.
409 * @param {string} itemId
410 * @param {string} itemType - 'task', 'event', or 'block'
411 * @param {number} deltaMinutes - Minutes to shift
412 * @param {number} duration - Item duration in minutes
413 */
414 async function rescheduleItem(itemId, itemType, deltaMinutes, duration) {
415 if (!GoingsOn.state.dayPlanData) return;
416
417 const item = GoingsOn.state.dayPlanData.timelineItems.find(i => i.id === itemId);
418 if (!item) return;
419
420 const currentStart = new Date(item.startTime);
421 const newStart = new Date(currentStart.getTime() + deltaMinutes * 60000);
422 const newEnd = new Date(newStart.getTime() + duration * 60000);
423
424 // Bounds check
425 if (newStart.getHours() < 0 || newEnd.getHours() > 23 && newEnd.getMinutes() > 0) return;
426
427 try {
428 if (itemType === 'task') {
429 await GoingsOn.api.dayPlanning.scheduleTask(itemId, {
430 startTime: newStart.toISOString(),
431 duration,
432 });
433 } else {
434 await GoingsOn.api.events.update(itemId, {
435 startTime: newStart.toISOString(),
436 endTime: newEnd.toISOString(),
437 });
438 }
439 await load();
440 } catch (err) {
441 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to reschedule'), 'error');
442 await load(); // reload to reset position
443 }
444 }
445
446 // ============ Populate GoingsOn.dayPlan Namespace ============
447
448 GoingsOn.dayPlan = {
449 load,
450 cleanup,
451 // Navigation
452 previousDay,
453 nextDay,
454 goToToday,
455 onDatePickerChange,
456 // Timeline
457 openTimelineItem,
458 handleTimelineItemKeydown,
459 moveScheduledTask,
460 unscheduleTask,
461 // Unscheduled tasks
462 handleUnscheduledTaskKeydown,
463 // Drag-to-reschedule
464 onItemDragStart,
465 // Painting (delegated to dayPlanPaint)
466 onPaintStart: (...a) => GoingsOn.dayPlanPaint.onPaintStart(...a),
467 onPaintMove: (...a) => GoingsOn.dayPlanPaint.onPaintMove(...a),
468 togglePaintMode: (...a) => GoingsOn.dayPlanPaint.togglePaintMode(...a),
469 updatePaintTimePreview: (...a) => GoingsOn.dayPlanPaint.updatePaintTimePreview(...a),
470 submitPaintedEvent: (...a) => GoingsOn.dayPlanPaint.submitPaintedEvent(...a),
471 // Scheduling modal (delegated to dayPlanSchedule)
472 openScheduleTaskModal: (...a) => GoingsOn.dayPlanSchedule.openScheduleTaskModal(...a),
473 selectTimeSlot: (...a) => GoingsOn.dayPlanSchedule.selectTimeSlot(...a),
474 selectDuration: (...a) => GoingsOn.dayPlanSchedule.selectDuration(...a),
475 scheduleTaskFromModal: (...a) => GoingsOn.dayPlanSchedule.scheduleTaskFromModal(...a),
476 // Daily review (delegated to dayPlanSchedule)
477 openFinishReviewModal: (...a) => GoingsOn.dayPlanSchedule.openFinishReviewModal(...a),
478 saveDailyReview: (...a) => GoingsOn.dayPlanSchedule.saveDailyReview(...a),
479 // Mobile
480 onSlotTap,
481 toggleSidebar,
482 };
483
484 })();
485