/** * GoingsOn - Day Planning Module * Core load/render, navigation, mobile, keyboard, cleanup. * Painting lives in day-planning-paint.js. * Schedule modal + daily review live in day-planning-schedule.js. * Timeline rendering lives in day-planning-render.js. */ (function() { 'use strict'; // ============ Day Planning State ============ // dayPlanDate, dayPlanData, currentTimeIndicatorInterval, paintingState // all live on GoingsOn.state (single source of truth) let unscheduledTasksScroller = null; // ============ Day Planning Functions ============ const formatDateForApi = GoingsOn.utils.formatDateForApi; const formatDateDisplay = GoingsOn.utils.formatDateDisplay; /** * Load day planning data for the current date and render timeline + sidebar. */ async function load() { const dateStr = formatDateForApi(GoingsOn.state.dayPlanDate); // Update date picker and display document.getElementById('day-plan-date').value = dateStr; document.getElementById('day-plan-date-display').textContent = formatDateDisplay(GoingsOn.state.dayPlanDate); try { GoingsOn.state.set('dayPlanData', await GoingsOn.api.dayPlanning.getDay(dateStr)); renderTimeline(); renderUnscheduledTasks(); // Render time summary in sidebar if (GoingsOn.timeSummary) { GoingsOn.timeSummary.render(document.getElementById('time-summary-container')); } // Scroll to current time on initial load updateCurrentTimeIndicator(true); // Start current time indicator update (without auto-scroll) if (GoingsOn.state.currentTimeIndicatorInterval) { clearInterval(GoingsOn.state.currentTimeIndicatorInterval); } GoingsOn.state.set('currentTimeIndicatorInterval', setInterval(() => updateCurrentTimeIndicator(false), 60000)); // Render the inline "Accomplished" list and refresh nudge state GoingsOn.dayPlanSchedule.loadDayReviewPane(); // Initialize mobile swipe navigation (once per load) if (!swipeNavCleanup) initSwipeDayNav(); } catch (err) { console.error('Failed to load day planning:', err); GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load day planning'), 'error', { action: { label: 'Retry', fn: load }, duration: 8000, }); } } function renderTimeline() { GoingsOn.dayPlanRender.renderTimeline(GoingsOn.state.dayPlanDate, GoingsOn.state.dayPlanData); wireTouchInteractions(); } // Per-render touch wiring. Cleanup happens implicitly when the slot/item elements // are replaced on next render. let touchCleanups = []; function wireTouchInteractions() { if (!GoingsOn.touch?.isTouchDevice) return; touchCleanups.forEach(fn => fn()); touchCleanups = []; // Long-press on empty slots opens the picker with a 1-hour default range. // Snap to the nearest 30-min boundary so finger imprecision doesn't land on // a quirky :15/:45 start time. document.querySelectorAll('#timeline-slots .timeline-slot').forEach(slot => { const idx = parseInt(slot.dataset.slotIndex, 10); const snapped = Math.floor(idx / 2) * 2; touchCleanups.push(GoingsOn.touch.addLongPress(slot, () => { if (GoingsOn.state.paintingState) return; const start = GoingsOn.dayPlanPaint.slotToTime(snapped); const end = GoingsOn.dayPlanPaint.slotToTime(snapped + 4); // 1 hour GoingsOn.dayPlanPaint.openPaintedEventModal(start, end); })); }); // Long-press on timeline items opens an action sheet (Open, Reschedule, Unschedule for tasks). document.querySelectorAll('#timeline-items .timeline-item').forEach(item => { const id = item.dataset.id; const type = item.dataset.type; touchCleanups.push(GoingsOn.touch.addLongPress(item, () => { openItemActionSheet(id, type); })); }); } function openItemActionSheet(id, type) { const items = [ { label: 'Open', action: () => openTimelineItem(id, type) }, ]; if (type === 'task') { items.push({ label: 'Reschedule', action: () => GoingsOn.dayPlanSchedule.openScheduleTaskModal(id), }); items.push({ label: 'Unschedule', danger: true, action: () => unscheduleTask(id), }); } else if (type === 'event' || type === 'block') { items.push({ label: 'Edit time', action: () => GoingsOn.events.openEdit(id), }); } GoingsOn.ui.showActionSheet(items); } function renderUnscheduledTasks() { const container = document.getElementById('unscheduled-tasks'); if (!GoingsOn.state.dayPlanData || GoingsOn.state.dayPlanData.unscheduledTasks.length === 0) { if (unscheduledTasksScroller) { unscheduledTasksScroller.destroy(); unscheduledTasksScroller = null; } container.innerHTML = '
Nothing unscheduled — enjoy your day.
'; return; } if (!unscheduledTasksScroller) { unscheduledTasksScroller = new GoingsOn.VirtualScroller({ container: container, renderItem: GoingsOn.dayPlanRender.renderUnscheduledTaskItem, getItems: () => GoingsOn.state.dayPlanData?.unscheduledTasks || [], rowHeight: { estimated: 60, measure: true }, overscan: 3, }); } else { unscheduledTasksScroller.refresh(); } } function updateCurrentTimeIndicator(scrollToTime = false) { GoingsOn.dayPlanRender.updateCurrentTimeIndicator(GoingsOn.state.dayPlanDate, formatDateForApi, scrollToTime); } /** * Open a timeline item based on its type (task subtasks or event detail). * @param {string} id - Item ID * @param {string} itemType - 'task', 'event', or 'block' */ function openTimelineItem(id, itemType) { if (itemType === 'task') { GoingsOn.tasks.openSubtasks(id); } else if (itemType === 'event' || itemType === 'block') { GoingsOn.events.open(id); } } // ============ Date Navigation ============ function previousDay() { const prev = new Date(GoingsOn.state.dayPlanDate); prev.setDate(prev.getDate() - 1); GoingsOn.state.set('dayPlanDate', prev); load(); } function nextDay() { const next = new Date(GoingsOn.state.dayPlanDate); next.setDate(next.getDate() + 1); GoingsOn.state.set('dayPlanDate', next); load(); } function goToToday() { GoingsOn.state.set('dayPlanDate', new Date()); load(); } function onDatePickerChange() { const picker = document.getElementById('day-plan-date'); GoingsOn.state.set('dayPlanDate', new Date(picker.value + 'T12:00:00')); load(); } // ============ Mobile: Tap-to-Create ============ /** * On touch devices, tapping a time slot opens the create modal * with the selected time pre-filled (30min default duration). */ function onSlotTap(event, slotIndex, slotTime) { if (!GoingsOn.touch?.isTouchDevice) return; // Ignore if it was part of a paint drag if (GoingsOn.state.paintingState) return; // Ignore taps on items if (event.target.closest('.timeline-item')) return; // Snap to 30-min grid on touch to absorb finger imprecision. const snapped = Math.floor(slotIndex / 2) * 2; const startTime = GoingsOn.dayPlanPaint.slotToTime(snapped); const endTime = GoingsOn.dayPlanPaint.slotToTime(snapped + 2); // 30min = 2 slots GoingsOn.dayPlanPaint.openPaintedEventModal(startTime, endTime); } // ============ Mobile: Swipe Day Navigation ============ let swipeNavCleanup = null; function initSwipeDayNav() { if (!GoingsOn.touch?.isTouchDevice) return; const container = document.getElementById('timeline-container'); if (!container) return; swipeNavCleanup = GoingsOn.touch.addSwipeNavigation(container, { onLeft: nextDay, onRight: previousDay, }); } // ============ Mobile: Collapsible Sidebar ============ function toggleSidebar() { const sidebar = document.querySelector('.day-plan-sidebar'); if (sidebar) sidebar.classList.toggle('collapsed'); } // ============ View Cleanup ============ function cleanup() { if (GoingsOn.state.currentTimeIndicatorInterval) { clearInterval(GoingsOn.state.currentTimeIndicatorInterval); GoingsOn.state.set('currentTimeIndicatorInterval', null); } if (GoingsOn.state.paintingState) { document.removeEventListener('mouseup', GoingsOn.dayPlanPaint.onPaintEnd); if (GoingsOn.state.paintingState.preview) { GoingsOn.state.paintingState.preview.remove(); } GoingsOn.state.set('paintingState', null); const container = document.getElementById('timeline-container'); if (container) { container.classList.remove('is-painting'); } } if (unscheduledTasksScroller) { unscheduledTasksScroller.destroy(); unscheduledTasksScroller = null; } if (swipeNavCleanup) { swipeNavCleanup(); swipeNavCleanup = null; } } // ============ Keyboard Accessibility ============ function handleUnscheduledTaskKeydown(event, taskId) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); GoingsOn.tasks.openSubtasks(taskId); } else if (event.key === 's' || event.key === 'S') { event.preventDefault(); GoingsOn.dayPlanSchedule.openScheduleTaskModal(taskId); } } async function handleTimelineItemKeydown(event, itemId, itemType) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTimelineItem(itemId, itemType); return; } if (itemType !== 'task') return; if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); await moveScheduledTask(itemId, event.key === 'ArrowUp' ? -15 : 15); } else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); await unscheduleTask(itemId); } } /** * Move a scheduled task's start time by a delta (keyboard arrow keys). * @param {string} taskId - Scheduled task ID * @param {number} deltaMinutes - Minutes to shift (+15 or -15) */ async function moveScheduledTask(taskId, deltaMinutes) { if (!GoingsOn.state.dayPlanData) return; const item = GoingsOn.state.dayPlanData.timelineItems.find(i => i.id === taskId && i.itemType === 'task'); if (!item) return; const currentStart = new Date(item.startTime); const newStart = new Date(currentStart.getTime() + deltaMinutes * 60 * 1000); const hour = newStart.getHours(); const minute = newStart.getMinutes(); if (hour < 0 || (hour === 23 && minute > 45) || hour > 23) { return; } try { await GoingsOn.api.dayPlanning.scheduleTask(taskId, { startTime: newStart.toISOString(), duration: item.duration || 30 }); await load(); requestAnimationFrame(() => { const movedItem = document.querySelector(`.timeline-item[data-id="${taskId}"]`); if (movedItem) movedItem.focus(); }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move task'), 'error'); } } /** * Remove a task from the day's schedule. * @param {string} taskId - Task ID to unschedule */ async function unscheduleTask(taskId) { try { await GoingsOn.api.dayPlanning.unscheduleTask(taskId); GoingsOn.ui.showToast('Task unscheduled', 'success'); await load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to unschedule task'), 'error'); } } // ============ Drag-to-Reschedule ============ let dragState = null; /** * Start dragging a timeline item to reschedule it. * @param {MouseEvent} event * @param {string} itemId - Item ID * @param {string} itemType - 'task', 'event', or 'block' */ function onItemDragStart(event, itemId, itemType) { if (event.button !== 0) return; const el = event.currentTarget; const slotsContainer = document.getElementById('timeline-slots'); if (!slotsContainer) return; const startY = event.clientY; const origTop = parseFloat(el.style.top); const slotHeight = GoingsOn.dayPlanRender.getSlotHeight(); let moved = false; function onMouseMove(e) { const dy = e.clientY - startY; if (!moved && Math.abs(dy) < 5) return; // dead zone to distinguish click from drag moved = true; el.classList.add('dragging'); // Snap to 15-min slots const slotDelta = Math.round(dy / slotHeight); const newTop = origTop + slotDelta * slotHeight; el.style.top = `${Math.max(0, newTop)}px`; } function onMouseUp(e) { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); el.classList.remove('dragging'); if (!moved) return; // was a click, not a drag — let onclick handle it // Prevent the click event from firing after drag el.addEventListener('click', function suppress(ev) { ev.stopPropagation(); el.removeEventListener('click', suppress, true); }, { capture: true, once: true }); const dy = e.clientY - startY; const slotDelta = Math.round(dy / slotHeight); if (slotDelta === 0) return; const deltaMinutes = slotDelta * 15; rescheduleItem(itemId, itemType, deltaMinutes, parseInt(el.dataset.duration) || 30); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); } /** * Reschedule a timeline item by a time delta. * @param {string} itemId * @param {string} itemType - 'task', 'event', or 'block' * @param {number} deltaMinutes - Minutes to shift * @param {number} duration - Item duration in minutes */ async function rescheduleItem(itemId, itemType, deltaMinutes, duration) { if (!GoingsOn.state.dayPlanData) return; const item = GoingsOn.state.dayPlanData.timelineItems.find(i => i.id === itemId); if (!item) return; const currentStart = new Date(item.startTime); const newStart = new Date(currentStart.getTime() + deltaMinutes * 60000); const newEnd = new Date(newStart.getTime() + duration * 60000); // Bounds check if (newStart.getHours() < 0 || newEnd.getHours() > 23 && newEnd.getMinutes() > 0) return; try { if (itemType === 'task') { await GoingsOn.api.dayPlanning.scheduleTask(itemId, { startTime: newStart.toISOString(), duration, }); } else { await GoingsOn.api.events.update(itemId, { startTime: newStart.toISOString(), endTime: newEnd.toISOString(), }); } await load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to reschedule'), 'error'); await load(); // reload to reset position } } // ============ Populate GoingsOn.dayPlan Namespace ============ GoingsOn.dayPlan = { load, cleanup, // Navigation previousDay, nextDay, goToToday, onDatePickerChange, // Timeline openTimelineItem, handleTimelineItemKeydown, moveScheduledTask, unscheduleTask, // Unscheduled tasks handleUnscheduledTaskKeydown, // Drag-to-reschedule onItemDragStart, // Painting (delegated to dayPlanPaint) onPaintStart: (...a) => GoingsOn.dayPlanPaint.onPaintStart(...a), onPaintMove: (...a) => GoingsOn.dayPlanPaint.onPaintMove(...a), togglePaintMode: (...a) => GoingsOn.dayPlanPaint.togglePaintMode(...a), updatePaintTimePreview: (...a) => GoingsOn.dayPlanPaint.updatePaintTimePreview(...a), submitPaintedEvent: (...a) => GoingsOn.dayPlanPaint.submitPaintedEvent(...a), // Scheduling modal (delegated to dayPlanSchedule) openScheduleTaskModal: (...a) => GoingsOn.dayPlanSchedule.openScheduleTaskModal(...a), selectTimeSlot: (...a) => GoingsOn.dayPlanSchedule.selectTimeSlot(...a), selectDuration: (...a) => GoingsOn.dayPlanSchedule.selectDuration(...a), scheduleTaskFromModal: (...a) => GoingsOn.dayPlanSchedule.scheduleTaskFromModal(...a), // Daily review (delegated to dayPlanSchedule) openFinishReviewModal: (...a) => GoingsOn.dayPlanSchedule.openFinishReviewModal(...a), saveDailyReview: (...a) => GoingsOn.dayPlanSchedule.saveDailyReview(...a), // Mobile onSlotTap, toggleSidebar, }; })();