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