Skip to main content

max / goingson

15.5 KB · 437 lines History Blame Raw
1 /**
2 * GoingsOn - Mobile Interaction Wiring
3 * Connects touch.js gesture utilities to list views using event delegation.
4 * Handles swipe-to-action, pull-to-refresh, and long-press selection.
5 * All no-ops on non-touch devices.
6 */
7
8 (function() {
9 'use strict';
10
11 if (!GoingsOn.touch?.isTouchDevice) return;
12
13 // ============ Swipe Delegation ============
14
15 /**
16 * Add delegated swipe handling to a container. Tracks the swiped row
17 * internally so virtual-scroller DOM recycling doesn't cause issues.
18 *
19 * @param {HTMLElement} container - Scrollable container (e.g. #task-list-container)
20 * @param {Object} config
21 * @param {string} config.rowSelector - CSS selector for swipeable rows
22 * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null
23 * @param {number} [config.threshold=80] - Pixels to trigger action
24 */
25 function addSwipeDelegate(container, config) {
26 const threshold = config.threshold || 80;
27 let activeRow = null;
28 let startX = 0;
29 let startY = 0;
30 let currentX = 0;
31 let isDragging = false;
32 let isHorizontal = null;
33 let actions = null;
34
35 // Phase 7 Tier 3 #11 — peek-label affordance. As the user drags past
36 // the threshold, surface the action that will fire ("Complete" /
37 // "Snooze" / "Archive" / "Delete") so the gesture is discoverable.
38 function attachPeekLabels(row, actions) {
39 if (actions.right?.label) {
40 const peek = document.createElement('div');
41 peek.className = 'swipe-peek swipe-peek--right'
42 + (actions.right.kind ? ' swipe-peek--' + actions.right.kind : '');
43 peek.textContent = actions.right.label;
44 row.appendChild(peek);
45 }
46 if (actions.left?.label) {
47 const peek = document.createElement('div');
48 peek.className = 'swipe-peek swipe-peek--left'
49 + (actions.left.kind ? ' swipe-peek--' + actions.left.kind : '');
50 peek.textContent = actions.left.label;
51 row.appendChild(peek);
52 }
53 }
54 function updatePeek(row, currentX, threshold) {
55 const ratio = Math.min(1, Math.abs(currentX) / threshold);
56 const peek = row.querySelector(currentX >= 0 ? '.swipe-peek--right' : '.swipe-peek--left');
57 const otherPeek = row.querySelector(currentX >= 0 ? '.swipe-peek--left' : '.swipe-peek--right');
58 if (peek) {
59 peek.style.opacity = String(ratio);
60 peek.classList.toggle('swipe-peek--ready', Math.abs(currentX) >= threshold);
61 }
62 if (otherPeek) {
63 otherPeek.style.opacity = '0';
64 otherPeek.classList.remove('swipe-peek--ready');
65 }
66 }
67 function clearPeek(row) {
68 row.querySelectorAll('.swipe-peek').forEach(el => el.remove());
69 }
70
71 container.addEventListener('touchstart', function(e) {
72 const row = e.target.closest(config.rowSelector);
73 if (!row) return;
74
75 actions = config.getActions(row);
76 if (!actions) return;
77
78 activeRow = row;
79 const touch = e.touches[0];
80 startX = touch.clientX;
81 startY = touch.clientY;
82 currentX = 0;
83 isDragging = true;
84 isHorizontal = null;
85 activeRow.style.transition = 'none';
86 attachPeekLabels(activeRow, actions);
87 }, { passive: true });
88
89 container.addEventListener('touchmove', function(e) {
90 if (!isDragging || !activeRow) return;
91
92 const touch = e.touches[0];
93 const dx = touch.clientX - startX;
94 const dy = touch.clientY - startY;
95
96 // Determine direction on first significant move
97 if (isHorizontal === null) {
98 if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
99 isHorizontal = Math.abs(dx) > Math.abs(dy);
100 }
101 if (!isHorizontal) return;
102 }
103 if (!isHorizontal) return;
104
105 e.preventDefault();
106 currentX = dx;
107
108 // Clamp to allowed directions
109 if (dx < 0 && !actions.left) currentX = 0;
110 if (dx > 0 && !actions.right) currentX = 0;
111
112 // Rubber-band past threshold
113 const maxSwipe = threshold * 1.5;
114 if (Math.abs(currentX) > threshold) {
115 const overshoot = Math.abs(currentX) - threshold;
116 const dampened = threshold + overshoot * 0.3;
117 currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe);
118 }
119
120 activeRow.style.transform = `translateX(${currentX}px)`;
121 updatePeek(activeRow, currentX, threshold);
122 }, { passive: false });
123
124 function onEnd() {
125 if (!isDragging || !activeRow) return;
126 isDragging = false;
127
128 activeRow.style.transition = 'transform 0.2s ease';
129
130 if (Math.abs(currentX) >= threshold) {
131 if (currentX < 0 && actions?.left?.action) {
132 actions.left.action();
133 } else if (currentX > 0 && actions?.right?.action) {
134 actions.right.action();
135 }
136 }
137
138 activeRow.style.transform = 'translateX(0)';
139 const row = activeRow;
140 setTimeout(() => clearPeek(row), 200);
141 activeRow = null;
142 actions = null;
143 currentX = 0;
144 isHorizontal = null;
145 }
146
147 container.addEventListener('touchend', onEnd, { passive: true });
148 container.addEventListener('touchcancel', onEnd, { passive: true });
149 }
150
151 // ============ Long-Press Delegation ============
152
153 /**
154 * Add delegated long-press handling to a container for selection mode.
155 * @param {HTMLElement} container
156 * @param {string} rowSelector - CSS selector for pressable rows
157 * @param {Function} onLongPress - (rowEl) => void
158 */
159 function addLongPressDelegate(container, rowSelector, onLongPress) {
160 let timer = null;
161 let startX = 0;
162 let startY = 0;
163 let activeRow = null;
164 const MOVE_THRESHOLD = 10;
165 const DURATION = 500;
166
167 container.addEventListener('touchstart', function(e) {
168 const row = e.target.closest(rowSelector);
169 if (!row) return;
170 activeRow = row;
171
172 const touch = e.touches[0];
173 startX = touch.clientX;
174 startY = touch.clientY;
175
176 timer = setTimeout(() => {
177 timer = null;
178 // Prevent subsequent click
179 activeRow.addEventListener('click', function prevent(ev) {
180 ev.preventDefault();
181 ev.stopPropagation();
182 }, { once: true, capture: true });
183 onLongPress(activeRow);
184 }, DURATION);
185 }, { passive: true });
186
187 container.addEventListener('touchmove', function(e) {
188 if (!timer) return;
189 const touch = e.touches[0];
190 if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD ||
191 Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) {
192 clearTimeout(timer);
193 timer = null;
194 }
195 }, { passive: true });
196
197 function cancel() {
198 if (timer) {
199 clearTimeout(timer);
200 timer = null;
201 }
202 activeRow = null;
203 }
204
205 container.addEventListener('touchend', cancel, { passive: true });
206 container.addEventListener('touchcancel', cancel, { passive: true });
207 }
208
209 // ============ Wire Everything on Init ============
210
211 function init() {
212 wireTaskSwipe();
213 wireEmailSwipe();
214 wireEventSwipe();
215 wirePullToRefresh();
216 wirePullToRefreshReviews();
217 wireTimeViewSwipe();
218 wireLongPress();
219 wireKeyboardScrollIntoView();
220 }
221
222 // ============ Keyboard Avoidance ============
223 // When the virtual keyboard opens on mobile, focused inputs near the
224 // bottom of the screen get obscured. visualViewport reports the visible
225 // area after keyboard inset; if the focused element overlaps that bottom
226 // edge, scroll it into view.
227 function wireKeyboardScrollIntoView() {
228 const vv = window.visualViewport;
229 if (!vv) return;
230
231 function maybeScrollFocused() {
232 const el = document.activeElement;
233 if (!el) return;
234 const tag = el.tagName;
235 if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
236 // visualViewport.height shrinks when keyboard is open.
237 const rect = el.getBoundingClientRect();
238 const visibleBottom = vv.height + vv.offsetTop;
239 const margin = 24;
240 if (rect.bottom > visibleBottom - margin) {
241 el.scrollIntoView({ block: 'center', behavior: 'smooth' });
242 }
243 }
244
245 document.addEventListener('focusin', () => {
246 // Defer so visualViewport has updated after keyboard animates in.
247 setTimeout(maybeScrollFocused, 250);
248 });
249 vv.addEventListener('resize', maybeScrollFocused);
250 }
251
252 // --- Tasks ---
253
254 function wireTaskSwipe() {
255 const container = document.getElementById('task-list-container');
256 if (!container) return;
257
258 addSwipeDelegate(container, {
259 rowSelector: '.task-row',
260 getActions: (row) => {
261 const id = row.dataset.id;
262 if (!id) return null;
263 return {
264 right: { label: 'Complete', kind: 'success', action: () => GoingsOn.tasks.complete(id) },
265 left: { label: 'Snooze', kind: 'warn', action: () => GoingsOn.snooze.openModal('task', id) },
266 };
267 },
268 });
269 }
270
271 // --- Emails ---
272
273 function wireEmailSwipe() {
274 const container = document.getElementById('email-list');
275 if (!container) return;
276
277 addSwipeDelegate(container, {
278 rowSelector: '.email-item',
279 getActions: (row) => {
280 const id = row.dataset.id;
281 if (!id) return null;
282 return {
283 right: { label: 'Archive', kind: 'success', action: () => GoingsOn.emails.archive(id) },
284 left: { label: 'Delete', kind: 'danger', action: () => GoingsOn.emails.delete(id) },
285 };
286 },
287 });
288 }
289
290 // --- Events ---
291
292 function wireEventSwipe() {
293 const upcomingContainer = document.getElementById('event-list-container');
294 const pastContainer = document.getElementById('past-event-list-container');
295
296 function getEventActions(row) {
297 const id = row.dataset.id;
298 if (!id) return null;
299 return {
300 left: { label: 'Delete', kind: 'danger', action: () => GoingsOn.events.delete(id) },
301 };
302 }
303
304 if (upcomingContainer) {
305 addSwipeDelegate(upcomingContainer, {
306 rowSelector: '.event-row-virtual',
307 getActions: getEventActions,
308 });
309 }
310 if (pastContainer) {
311 addSwipeDelegate(pastContainer, {
312 rowSelector: '.event-row-virtual',
313 getActions: getEventActions,
314 });
315 }
316 }
317
318 // --- Pull to Refresh ---
319
320 function wirePullToRefresh() {
321 const taskContainer = document.getElementById('task-list-container');
322 const emailContainer = document.getElementById('email-list');
323 const eventContainer = document.getElementById('event-list-container');
324
325 if (taskContainer) {
326 GoingsOn.touch.addPullToRefresh(taskContainer, () => GoingsOn.tasks.load());
327 }
328 if (emailContainer) {
329 GoingsOn.touch.addPullToRefresh(emailContainer, () => GoingsOn.emails.load());
330 }
331 if (eventContainer) {
332 GoingsOn.touch.addPullToRefresh(eventContainer, () => GoingsOn.events.load());
333 }
334 }
335
336 // --- Long-Press Selection ---
337
338 /**
339 * Toggle an item's selection state by directly manipulating the
340 * SelectionManager's selectedIds set + syncing the visible checkbox.
341 */
342 function toggleSelectionById(selectionManager, id) {
343 if (selectionManager.selectedIds.has(id)) {
344 selectionManager.selectedIds.delete(id);
345 } else {
346 selectionManager.selectedIds.add(id);
347 }
348 selectionManager._syncVisibleCheckboxes();
349 selectionManager.updateBulkActionsBar();
350 }
351
352 function wireLongPress() {
353 const taskContainer = document.getElementById('task-list-container');
354 const emailContainer = document.getElementById('email-list');
355
356 if (taskContainer) {
357 addLongPressDelegate(taskContainer, '.task-row', (row) => {
358 const id = row.dataset.id;
359 if (id) toggleSelectionById(GoingsOn.tasks.selection, id);
360 });
361 }
362 if (emailContainer) {
363 addLongPressDelegate(emailContainer, '.email-item', (row) => {
364 const id = row.dataset.id;
365 if (id) toggleSelectionById(GoingsOn.emails.selection, id);
366 });
367 }
368 }
369
370 // --- Pull to Refresh: Reviews ---
371
372 function wirePullToRefreshReviews() {
373 const monthlyContainer = document.getElementById('monthly-review-content');
374 const weeklyContainer = document.getElementById('weekly-review-content');
375
376 if (monthlyContainer) {
377 GoingsOn.touch.addPullToRefresh(monthlyContainer, () => GoingsOn.monthlyReview.load());
378 }
379 if (weeklyContainer) {
380 GoingsOn.touch.addPullToRefresh(weeklyContainer, () => GoingsOn.weeklyReview.load());
381 }
382 }
383
384 // --- Swipe Nav: Day/Week/Month ---
385
386 function wireTimeViewSwipe() {
387 const timeView = document.getElementById('time-view');
388 if (!timeView) return;
389
390 const views = ['day-plan', 'weekly-review', 'monthly-review'];
391
392 function getCurrentIndex() {
393 const current = GoingsOn.state.currentView;
394 const idx = views.indexOf(current);
395 return idx >= 0 ? idx : 0;
396 }
397
398 let skipSwipe = false;
399
400 timeView.addEventListener('touchstart', (e) => {
401 // Skip swipe if touch started inside day-plan timeline (has its own day swipe)
402 skipSwipe = !!e.target.closest('#timeline-container');
403 }, { passive: true });
404
405 GoingsOn.touch.addSwipeNavigation(timeView, {
406 onLeft: () => {
407 if (skipSwipe) return;
408 const idx = getCurrentIndex();
409 if (idx < views.length - 1) {
410 GoingsOn.navigation.switchView(views[idx + 1]);
411 }
412 },
413 onRight: () => {
414 if (skipSwipe) return;
415 const idx = getCurrentIndex();
416 if (idx > 0) {
417 GoingsOn.navigation.switchView(views[idx - 1]);
418 }
419 },
420 });
421 }
422
423 // ============ Populate Namespace ============
424
425 GoingsOn.mobile = { init };
426
427 // Initialize after DOM is ready. app.js calls init() which loads views,
428 // so we listen for the same event or defer to next tick.
429 if (document.readyState === 'loading') {
430 document.addEventListener('DOMContentLoaded', init);
431 } else {
432 // DOM already ready — defer to let domain modules register first
433 setTimeout(init, 0);
434 }
435
436 })();
437