Skip to main content

max / goingson

13.5 KB · 322 lines History Blame Raw
1 /**
2 * GoingsOn - Day Planning Paint Module
3 * Timeline painting to create events, painted event modal, time block types.
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 // ============ Utility ============
12
13 /**
14 * Convert a 15-minute slot index (0-95) to a Date object on the current day plan date.
15 * @param {number} slotIndex - Slot index (0 = 00:00, 95 = 23:45)
16 * @returns {Date} Date object for the slot
17 */
18 function slotToTime(slotIndex) {
19 const hour = Math.floor(slotIndex / 4);
20 const minute = (slotIndex % 4) * 15;
21 const date = new Date(GoingsOn.state.dayPlanDate);
22 date.setHours(hour, minute, 0, 0);
23 return date;
24 }
25
26 const toLocalISOString = GoingsOn.utils.toLocalISOString;
27
28 // ============ Painting to Create Events ============
29
30 /**
31 * Begin painting a time range on mousedown.
32 * @param {MouseEvent} event
33 * @param {number} slotIndex - Starting slot index
34 * @param {string} slotTime - ISO timestamp of the slot
35 */
36 function onPaintStart(event, slotIndex, slotTime) {
37 if (event.button !== 0) return;
38 if (event.target.closest('.timeline-item')) return;
39 // Mobile UI doesn't expose drag-paint — tap-to-add is the touch path.
40 if (GoingsOn.viewport?.isMobile()) return;
41
42 event.preventDefault();
43
44 const container = document.getElementById('timeline-container');
45 container.classList.add('is-painting');
46
47 const preview = document.createElement('div');
48 preview.className = 'timeline-paint-preview';
49 document.getElementById('timeline-items').appendChild(preview);
50
51 GoingsOn.state.set('paintingState', {
52 startSlot: slotIndex,
53 startTime: slotTime,
54 endSlot: slotIndex,
55 endTime: slotTime,
56 preview
57 });
58
59 updatePaintPreview();
60 document.addEventListener('mouseup', onPaintEnd);
61 }
62
63 /**
64 * Extend the paint selection on mousemove.
65 * @param {MouseEvent} event
66 * @param {number} slotIndex - Current slot index
67 * @param {string} slotTime - ISO timestamp of the slot
68 */
69 function onPaintMove(event, slotIndex, slotTime) {
70 if (!GoingsOn.state.paintingState) return;
71 GoingsOn.state.paintingState.endSlot = slotIndex;
72 GoingsOn.state.paintingState.endTime = slotTime;
73 updatePaintPreview();
74 }
75
76 function onPaintEnd() {
77 if (!GoingsOn.state.paintingState) return;
78
79 document.removeEventListener('mouseup', onPaintEnd);
80 document.getElementById('timeline-container').classList.remove('is-painting');
81 GoingsOn.state.paintingState.preview.remove();
82
83 const startSlot = Math.min(GoingsOn.state.paintingState.startSlot, GoingsOn.state.paintingState.endSlot);
84 const endSlot = Math.max(GoingsOn.state.paintingState.startSlot, GoingsOn.state.paintingState.endSlot);
85
86 const startTime = slotToTime(startSlot);
87 const endTime = slotToTime(endSlot + 1);
88
89 GoingsOn.state.set('paintingState', null);
90 openPaintedEventModal(startTime, endTime);
91 }
92
93 function updatePaintPreview() {
94 if (!GoingsOn.state.paintingState) return;
95
96 const slotHeight = GoingsOn.dayPlanRender.getSlotHeight();
97 const startSlot = Math.min(GoingsOn.state.paintingState.startSlot, GoingsOn.state.paintingState.endSlot);
98 const endSlot = Math.max(GoingsOn.state.paintingState.startSlot, GoingsOn.state.paintingState.endSlot);
99
100 GoingsOn.state.paintingState.preview.style.top = `${startSlot * slotHeight}px`;
101 GoingsOn.state.paintingState.preview.style.height = `${(endSlot - startSlot + 1) * slotHeight}px`;
102 }
103
104 /**
105 * Open the create modal for a painted time range (event, block, or task link).
106 * @param {Date} startTime - Start of the painted range
107 * @param {Date} endTime - End of the painted range
108 */
109 function openPaintedEventModal(startTime, endTime) {
110 const startISO = toLocalISOString(startTime);
111 const endISO = toLocalISOString(endTime);
112
113 const unscheduledTasks = GoingsOn.state.dayPlanData?.unscheduledTasks || [];
114 const taskOptions = unscheduledTasks.map(t =>
115 `<option value="${t.id}">${esc(t.description)}</option>`
116 ).join('');
117
118 const content = `
119 <form id="painted-event-form">
120 <div class="form-group">
121 <label class="form-label">What to create</label>
122 <select class="form-select" name="item_mode" onchange="GoingsOn.dayPlan.togglePaintMode(this)">
123 <option value="event">Event</option>
124 <option value="block">Time Block</option>
125 <option value="task">Link to Task</option>
126 </select>
127 </div>
128
129 <div id="paint-task-fields" class="hidden">
130 <div class="form-group">
131 <label class="form-label">Task</label>
132 <select class="form-select" name="linked_task_id">
133 <option value="">-- Select a task --</option>
134 ${taskOptions}
135 </select>
136 </div>
137 </div>
138
139 <div id="paint-block-fields" class="hidden">
140 <div class="form-group">
141 <label class="form-label">Block Type</label>
142 <select class="form-select" name="block_type">
143 <option value="free_time">Free Time</option>
144 <option value="personal">Personal</option>
145 <option value="vacation">Vacation</option>
146 <option value="focus">Focus</option>
147 </select>
148 </div>
149 </div>
150
151 <div id="standalone-event-fields">
152 <div class="form-group">
153 <label class="form-label">Title</label>
154 <input type="text" class="form-input" name="title" placeholder="Event title">
155 </div>
156 <div class="form-group" id="paint-description-group">
157 <label class="form-label">Description</label>
158 <textarea class="form-textarea" name="description" placeholder="Details..."></textarea>
159 </div>
160 </div>
161
162 <div id="paint-time-preview" class="form-hint paint-time-preview"></div>
163 <div class="form-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
164 <div class="form-group">
165 <label class="form-label">Start</label>
166 <input type="datetime-local" class="form-input" name="start_time" value="${startISO}"
167 onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
168 </div>
169 <div class="form-group">
170 <label class="form-label">End</label>
171 <input type="datetime-local" class="form-input" name="end_time" value="${endISO}"
172 onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
173 </div>
174 </div>
175
176 <div class="form-group" id="paint-location-group">
177 <label class="form-label">Location</label>
178 <input type="text" class="form-input" name="location" placeholder="Zoom / Office / etc.">
179 </div>
180
181 <div class="form-actions">
182 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
183 <button type="button" class="btn btn-primary" onclick="GoingsOn.dayPlan.submitPaintedEvent()">Create</button>
184 </div>
185 </form>
186 `;
187 GoingsOn.ui.openModal('Create Event', content);
188
189 // Show initial time preview
190 updatePaintTimePreview();
191
192 // Default to "Link to Task" when there are unscheduled tasks
193 if (unscheduledTasks.length > 0) {
194 const modeSelect = document.querySelector('#painted-event-form select[name="item_mode"]');
195 if (modeSelect) {
196 modeSelect.value = 'task';
197 GoingsOn.dayPlan.togglePaintMode(modeSelect);
198 }
199 }
200 }
201
202 /**
203 * Toggle visibility of form fields based on the selected paint mode.
204 * @param {HTMLSelectElement} select - The mode selector element
205 */
206 function togglePaintMode(select) {
207 const mode = select.value;
208 const taskFields = document.getElementById('paint-task-fields');
209 const blockFields = document.getElementById('paint-block-fields');
210 const eventFields = document.getElementById('standalone-event-fields');
211 const descGroup = document.getElementById('paint-description-group');
212 const locationGroup = document.getElementById('paint-location-group');
213
214 taskFields.classList.toggle('hidden', mode !== 'task');
215 blockFields.classList.toggle('hidden', mode !== 'block');
216 eventFields.classList.toggle('hidden', mode === 'task');
217
218 // Hide description and location for blocks
219 if (descGroup) descGroup.classList.toggle('hidden', mode === 'block');
220 if (locationGroup) locationGroup.classList.toggle('hidden', mode === 'block');
221 }
222
223 /**
224 * Update the human-readable time preview above the datetime inputs.
225 */
226 function updatePaintTimePreview() {
227 const form = document.getElementById('painted-event-form');
228 const preview = document.getElementById('paint-time-preview');
229 if (!form || !preview) return;
230
231 const startVal = form.start_time?.value;
232 const endVal = form.end_time?.value;
233 if (!startVal || !endVal) { preview.textContent = ''; return; }
234
235 const start = new Date(startVal);
236 const end = new Date(endVal);
237 const timeOpts = { hour: 'numeric', minute: '2-digit' };
238 const duration = Math.round((end - start) / 60000);
239
240 let durationLabel = '';
241 if (duration > 0) {
242 const hours = Math.floor(duration / 60);
243 const mins = duration % 60;
244 durationLabel = hours > 0
245 ? (mins > 0 ? ` (${hours}h ${mins}m)` : ` (${hours}h)`)
246 : ` (${mins}m)`;
247 }
248
249 preview.textContent = `${start.toLocaleTimeString('en-US', timeOpts)} \u2013 ${end.toLocaleTimeString('en-US', timeOpts)}${durationLabel}`;
250 }
251
252 async function submitPaintedEvent() {
253 const form = document.getElementById('painted-event-form');
254 const mode = form.item_mode.value;
255 const startTime = new Date(form.start_time.value).toISOString();
256 const endTime = new Date(form.end_time.value).toISOString();
257 const duration = Math.round((new Date(form.end_time.value) - new Date(form.start_time.value)) / 60000);
258
259 try {
260 const timeOpts = { hour: 'numeric', minute: '2-digit' };
261 const startLabel = new Date(form.start_time.value).toLocaleTimeString('en-US', timeOpts);
262 const endLabel = new Date(form.end_time.value).toLocaleTimeString('en-US', timeOpts);
263 const timeRange = `${startLabel} \u2013 ${endLabel}`;
264
265 if (mode === 'task') {
266 const linkedTaskId = form.linked_task_id.value;
267 if (!linkedTaskId) {
268 GoingsOn.ui.showToast('Please select a task', 'error');
269 return;
270 }
271 await GoingsOn.api.dayPlanning.scheduleTask(linkedTaskId, { startTime, duration });
272 GoingsOn.ui.showToast(`Task scheduled for ${timeRange}`, 'success');
273 } else if (mode === 'block') {
274 const blockType = form.block_type.value;
275 const title = form.title.value.trim() || GoingsOn.dayPlanRender.BLOCK_TYPE_LABELS[blockType] || blockType;
276 await GoingsOn.api.events.create({
277 title,
278 description: '',
279 startTime,
280 endTime,
281 location: null,
282 projectId: null,
283 blockType,
284 });
285 GoingsOn.ui.showToast(`Time block created: ${timeRange}`, 'success');
286 } else {
287 if (!form.title.value.trim()) {
288 GoingsOn.ui.showToast('Title is required for standalone events', 'error');
289 return;
290 }
291 await GoingsOn.api.events.create({
292 title: form.title.value,
293 description: form.description.value || '',
294 startTime,
295 endTime,
296 location: form.location.value || null,
297 projectId: null,
298 });
299 GoingsOn.ui.showToast(`Event created: ${timeRange}`, 'success');
300 }
301 GoingsOn.ui.closeModal();
302 await GoingsOn.dayPlan.load();
303 } catch (err) {
304 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create'), 'error');
305 }
306 }
307
308 // ============ Populate GoingsOn.dayPlanPaint Namespace ============
309
310 GoingsOn.dayPlanPaint = {
311 slotToTime,
312 onPaintStart,
313 onPaintMove,
314 onPaintEnd,
315 openPaintedEventModal,
316 togglePaintMode,
317 updatePaintTimePreview,
318 submitPaintedEvent,
319 };
320
321 })();
322