1/** 2 * Calendar Module â CalDAV-backed month/week/year calendar. 3 */ 4 5import uiModule from './ui.js'; 6import spinnerModule from './spinner.js'; 7import * as Modals from './modalManager.js'; 8import { makeWindowDraggable } from './windowDrag.js'; 9import { attachColorPicker } from './colorPicker.js'; 10import { bindMenuDismiss } from './escMenuStack.js'; 11import { 12 WEEKDAYS, MONTHS, MON_SHORT, 13 CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, 14 _trashIcon, _moreIcon, _bellIcon, 15 _isCalBgImage, _calBgImageUrl, _calBgCss, 16 _calReadableTextColor, 17 _ds, _addDays, _shiftDT, _tzOffset, _localDateOf, 18} from './calendar/utils.js'; 19 20const API_BASE = window.location.origin; 21// Open a file picker, upload the chosen image, return the URL string. 22function _pickCalBgImage() { 23 return new Promise(resolve => { 24 const input = document.createElement('input'); 25 input.type = 'file'; 26 input.accept = 'image/*'; 27 input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; 28 document.body.appendChild(input); 29 let done = false; 30 const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; 31 input.addEventListener('change', async () => { 32 const file = input.files?.[0]; 33 if (!file) return finish(null); 34 const fd = new FormData(); 35 fd.append('files', file);
36 try { 37 const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); 38 const data = await res.json(); 39 const fileId = data.files?.[0]?.id; 40 if (!fileId) throw new Error('Upload failed'); 41 finish(`${API_BASE}/api/upload/${fileId}`); 42 } catch { finish(null); } 43 }); 44 setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); 45 input.click(); 46 }); 47} 48 49let _open = false; 50// Set when the calendar opens so the first month render scrolls today's 51// cell into view â the grid scrolls on mobile and today can sit below the 52// fold, so we always land on the current date. 53let _scrollToTodayOnOpen = false; 54let _currentDate = new Date(); 55let _events = []; 56let _allEvents = {}; 57let _fetchedRanges = []; 58let _calendars = []; 59let _hiddenCals = new Set(); 60let _hiddenTypes = new Set(); // event_type values to hide 61// "Only important" filter â when true, only events with importance 62// high/critical render, regardless of their category. Toggled via the "!" 63// chip; orthogonal to _hiddenTypes (which deals with event_type categories). 64let _onlyImportant = false; 65 66let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1'; 67let _selectedDay = null; 68let _view = 'month'; 69let _searchQuery = ''; 70let _escHandler = null; 71let _modal = null; 72 73let _dragUid = null; 74let _sidebarWasOpen = false; 75let _slideDir = 0; // -1 = prev, +1 = next, 0 = none 76 77// (Single undo stack lives at `_calUndoStack` further below; this used to 78// hold a one-deep `_lastUndo` which has been collapsed into that stack.) 79 80function _showCalUndoToast(label, undoFn) { 81 // Push onto the shared undo stack (also used by month drag-drop) so 82 // Cmd/Ctrl+Z and the toast button consume the same source of truth. 83 _pushCalUndo({ label, run: undoFn }); 84 const isMac = /Mac|iPhone|iPad/.test(navigator.platform || '') || /Mac/.test(navigator.userAgent || ''); 85 uiModule.showToast(label, { 86 action: 'Undo', 87 actionHint: isMac ? 'âZ' : 'Ctrl+Z', 88 duration: 6000, 89 onAction: _popAndRunCalUndo, 90 }); 91} 92 93// ââ API ââ 94 95function _rangeIsCached(start, end) { 96 // Check if [start, end] is fully covered by any single fetched range 97 for (const [s, e] of _fetchedRanges) { 98 if (s <= start && e >= end) return true; 99 } 100 return false; 101} 102 103function _filterPool(start, end) { 104 // Return all events in pool that overlap [start, end) 105 return Object.values(_allEvents).filter(ev => { 106 const evStart = ev.all_day ? ev.dtstart : _localDateOf(ev.dtstart); 107 const evEnd = ev.all_day ? ev.dtend : _localDateOf(ev.dtend || ev.dtstart); 108 return evStart < end && evEnd >= start; 109 }).sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); 110} 111 112async function _fetchEvents(start, end, force) { 113 if (!force && _rangeIsCached(start, end)) { 114 _events = _filterPool(start, end); 115 return; 116 } 117 // Render from pool immediately if we have any cached data 118 const hasCache = Object.keys(_allEvents).length > 0; 119 if (hasCache) _events = _filterPool(start, end); 120 const fetchPromise = fetch(`${API_BASE}/api/calendar/events?start=${start}&end=${end}`, { credentials: 'same-origin' }) 121 .then(r => { 122 if (!r.ok) throw new Error('HTTP ' + r.status); 123 return r.json(); 124 }) 125 .then(data => { 126 // On first fetch after cache load, replace pool entirely to avoid 127 // stale/duplicate UIDs from a previous backend (e.g. CalDAV â SQLite) 128 if (hasCache && _fetchedRanges.length === 0) _allEvents = {}; 129 (data.events || []).forEach(ev => { _allEvents[ev.uid] = ev; }); 130 _fetchedRanges.push([start, end]); 131 _events = _filterPool(start, end); 132 if (typeof _saveCache === 'function') _saveCache(); 133 // Re-render in background when new data arrives (if calendar still open) 134 if (_open && hasCache) _render(); 135 }) 136 .catch(e => { console.error('Calendar: failed to fetch events', e); }); 137 // If we have cache, don't block on fetch â return immediately so render is instant 138 if (hasCache) return; 139 // No cache â must await the fetch 140 await fetchPromise; 141} 142 143// Prefetch surrounding months in background â fire-and-forget, no blocking 144function _prefetchAdjacent() { 145 const ranges = []; 146 if (_view === 'month' || _view === 'week') { 147 // Prefetch ±2 months around current 148 for (let offset = -2; offset <= 2; offset++) { 149 if (offset === 0) continue; 150 const d = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + offset, 1); 151 ranges.push(_monthRange(d)); 152 } 153 } else if (_view === 'year') { 154 // Prefetch prev/next year 155 ranges.push([`${_currentDate.getFullYear() - 1}-01-01`, `${_currentDate.getFullYear()}-01-01`]); 156 ranges.push([`${_currentDate.getFullYear() + 1}-01-01`, `${_currentDate.getFullYear() + 2}-01-01`]); 157 } 158 // Fire all prefetches in parallel, ignore failures 159 for (const [s, e] of ranges) { 160 if (_rangeIsCached(s, e)) continue; 161 fetch(`${API_BASE}/api/calendar/events?start=${s}&end=${e}`, { credentials: 'same-origin' }) 162 .then(r => { 163 if (!r.ok) throw new Error('HTTP ' + r.status); 164 return r.json(); 165 }) 166 .then(d => {
167 (d.events || []).forEach(ev => { _allEvents[ev.uid] = ev; }); 168 _fetchedRanges.push([s, e]); 169 }) 170 .catch(() => {}); 171 } 172} 173 174let _calendarsError = null; 175// Guard so we only trigger an on-open CalDAV pull once per page load â 176// every list/render path calls _fetchCalendars, but we only want to 177// hit the remote server lazily on the first user open. 178let _caldavSyncedOnce = false; 179async function _fetchCalendars() { 180 _calendarsError = null; 181 try { 182 const res = await fetch(`${API_BASE}/api/calendar/calendars`, { credentials: 'same-origin' }); 183 const data = await res.json(); 184 _calendars = data.calendars || []; 185 if (data.error) _calendarsError = data.error; 186 _calendars.forEach((c, i) => { 187 if (!c.color || c.color.startsWith('<')) c.color = CAL_PALETTE[i % CAL_PALETTE.length]; 188 }); 189 } catch (e) { _calendars = []; _calendarsError = e.message || 'Connection failed'; } 190 191 // First open: fire a background CalDAV pull. We don't await â the 192 // initial render uses whatever's already cached locally, and the 193 // sync's writes show up on the next paint after it resolves. 194 if (!_caldavSyncedOnce) { 195 _caldavSyncedOnce = true; 196 _syncCaldav(false); 197 } 198} 199 200// Trigger a CalDAV pull. `interactive=true` waits for the result and 201// refreshes the UI; false fires-and-forgets (used on first open). Both 202// no-op silently if CalDAV isn't configured. 203async function _syncCaldav(interactive) { 204 try { 205 const res = await fetch(`${API_BASE}/api/calendar/sync`, { 206 method: 'POST', credentials: 'same-origin', 207 }); 208 const data = await res.json().catch(() => ({})); 209 if (interactive) return data; 210 // Background path: if the pull actually changed anything, drop 211 // local caches and re-render so new events appear. 212 const changed = (data.calendars || 0) > 0 && ((data.events || 0) > 0 || (data.deleted || 0) > 0); 213 if (changed) { 214 _allEvents = {}; _fetchedRanges = [];
215 try { localStorage.removeItem(LS_KEY); } catch (_) {} 216 await _fetchCalendars(); 217 _render(); 218 } 219 } catch (e) { 220 if (interactive) return { errors: [e.message || 'Sync failed'] }; 221 } 222} 223 224function _optimisticEvent(data, uid) { 225 const cal = _calendars.find(c => c.href === data.calendar_href) || _calendars[0]; 226 return { 227 uid, 228 summary: data.summary || '', 229 dtstart: data.dtstart, 230 dtend: data.dtend || data.dtstart, 231 all_day: !!data.all_day, 232 description: data.description || '', 233 location: data.location || '', 234 rrule: data.rrule || '', 235 calendar: cal?.name || '', 236 calendar_href: data.calendar_href || cal?.href || '', 237 // Per-event color override (including the bg:<url> sentinel for custom 238 // backgrounds) wins over the parent calendar's default hex. 239 color: (data.color !== undefined && data.color !== null) ? data.color : (cal?.color || ''), 240 }; 241} 242 243// v2 review error-handling MEDs: every fetch here previously checked 244// only `.then(r => r.json())` with no `r.ok` test. A 500/404 still 245// resolved the promise and the optimistic state got promoted to truth. 246// All three flows now inspect `r.ok` and roll back the optimistic 247// state + surface a toast on the failure path. 248async function _createEvent(data) { 249 const tempUid = 'temp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); 250 _allEvents[tempUid] = _optimisticEvent(data, tempUid); 251 fetch(`${API_BASE}/api/calendar/events`, { 252 method: 'POST', credentials: 'same-origin', 253 headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), 254 }).then(async r => { 255 if (!r.ok) throw new Error('HTTP ' + r.status); 256 return r.json(); 257 }).then(d => { 258 if (d.uid) { 259 delete _allEvents[tempUid]; 260 _allEvents[d.uid] = _optimisticEvent(data, d.uid); 261 _saveCache && _saveCache(); 262 if (_open) _render(); 263 } 264 }).catch((e) => { 265 delete _allEvents[tempUid]; 266 if (_open) _render(); 267 if (window.uiModule) window.uiModule.showError('Failed to create event: ' + (e?.message || 'unknown')); 268 }); 269 return { uid: tempUid }; 270} 271 272async function _updateEvent(uid, data) { 273 const merged = { ...(_allEvents[uid] || {}), ...data }; 274 const _preMergeBackup = _allEvents[uid]; 275 _allEvents[uid] = _optimisticEvent(merged, uid); 276 // For recurring events the uid is a compound "{base_uid}::{date}" â 277 // the backend resolves it to the base series row. After the update, 278 // other occurrences of the same series are stale. Wipe the cache so 279 // a re-fetch picks up fresh data (next render + prefetch handles it). 280 const isRecurring = uid.includes('::'); 281 fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { 282 method: 'PUT', credentials: 'same-origin', 283 headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), 284 }).then(r => { 285 if (!r.ok) throw new Error('HTTP ' + r.status); 286 if (isRecurring) { 287 _fetchedRanges = []; 288 localStorage.removeItem(LS_KEY); 289 } else { 290 _saveCache && _saveCache(); 291 } 292 }).catch((e) => { 293 if (_preMergeBackup) _allEvents[uid] = _preMergeBackup; 294 else delete _allEvents[uid]; 295 if (_open) _render(); 296 if (window.uiModule) window.uiModule.showError('Failed to update event: ' + (e?.message || 'unknown')); 297 }); 298 return { ok: true }; 299} 300 301async function _deleteEvent(uid) { 302 // Multiple "sibling" UIDs may need to vanish optimistically: 303 // 1. The exact uid the user clicked. 304 // 2. If the user clicked a RECURRING occurrence (uid contains "::"), 305 // the server deletes the master + every occurrence â so we strip 306 // the master uid AND every "master::*" expansion from the 307 // client-side caches too. Without this, deleting one day of a 308 // multi-day recurring task only removed THAT day visually; the 309 // other days kept rendering until the next full refresh. 310 // 3. If the user clicked the master, strip every "master::*" 311 // expansion (same prefix scan). 312 const masterUid = uid.includes('::') ? uid.split('::')[0] : uid; 313 const backups = {}; 314 const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::'); 315 316 for (const k of Object.keys(_allEvents)) { 317 if (_matches(k)) { 318 backups[k] = _allEvents[k]; 319 delete _allEvents[k]; 320 } 321 } 322 if (Array.isArray(_events)) { 323 _events = _events.filter(e => !(e && _matches(e.uid || ''))); 324 } 325 if (_open) _render(); 326 _updateBadge && _updateBadge(); 327 const isRecurring = uid.includes('::'); 328 fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { 329 method: 'DELETE', credentials: 'same-origin', 330 }).then(r => { 331 // 404 = the event was already deleted by another session/device. That's 332 // exactly the state we want, so treat it as success â don't restore the 333 // row, otherwise the user can never clear stale cached events that were 334 // deleted from desktop while mobile was open (and vice versa). 335 if (!r.ok && r.status !== 404) throw new Error('HTTP ' + r.status); 336 if (isRecurring) { 337 _fetchedRanges = []; 338 localStorage.removeItem(LS_KEY); 339 } else { 340 _saveCache && _saveCache(); 341 } 342 }).catch((e) => { 343 // Server rejected â restore every uid we optimistically stripped. 344 for (const [k, ev] of Object.entries(backups)) { 345 _allEvents[k] = ev; 346 if (Array.isArray(_events)) _events.push(ev); 347 } 348 if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown')); 349 if (_open) _render(); 350 }); 351 return { ok: true }; 352} 353 354// ââ Date helpers ââ 355// _ds, _addDays, _shiftDT, _localDateOf, _tzOffset live in ./calendar/utils.js 356// _monthRange / _weekRange / _today depend on _ds so they stay here. 357 358function _today() { return _ds(new Date()); } 359 360function _monthRange(d) { 361 const y = d.getFullYear(), m = d.getMonth(); 362 const first = new Date(y, m, 1); 363 const dow = (first.getDay() + 6) % 7; 364 const gs = new Date(y, m, 1 - dow); 365 const ge = new Date(gs); ge.setDate(gs.getDate() + 42); 366 return [_ds(gs), _ds(ge)]; 367} 368 369function _weekRange(d) { 370 const dow = (d.getDay() + 6) % 7; 371 const s = new Date(d); s.setDate(d.getDate() - dow); 372 const e = new Date(s); e.setDate(s.getDate() + 7); 373 return [_ds(s), _ds(e)]; 374} 375 376function _eventsForDay(dateStr) { 377 return _events.filter(e => { 378 if (!_eventVisible(e)) return false;
379 if (e.all_day) { 380 // Zero-duration all-day event (dtstart == dtend) is a single-day event 381 if (e.dtstart === e.dtend) return e.dtstart === dateStr; 382 return e.dtstart <= dateStr && e.dtend > dateStr; 383 } 384 // Multi-day timed events: show on each day they span 385 const startDate = _localDateOf(e.dtstart); 386 const endDate = _localDateOf(e.dtend); 387 if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr; 388 return startDate === dateStr; 389 }); 390} 391 392function _calColor(ev) { 393 // Custom bg-image colors fall back to the parent calendar's solid hex 394 // in spots that need a plain color (dots, multi-day bars, week tile 395 // borders). The full image is shown via _calItemBgStyle() where it 396 // makes sense (event-item rows). 397 if (_isCalBgImage(ev.color)) { 398 const c = _calendars.find(c => c.href === ev.calendar_href); 399 return c?.color || 'var(--accent)'; 400 } 401 if (ev.color && !ev.color.startsWith('<')) return ev.color; 402 const c = _calendars.find(c => c.href === ev.calendar_href); 403 return c?.color || 'var(--accent)'; 404} 405 406function _calEventFg(ev) { 407 return _calReadableTextColor(_calColor(ev)); 408} 409 410// Extra inline style for an event row when the event has a custom BG image. 411// Returns '' for normal solid-color events. 412function _calItemBgStyle(ev) { 413 if (!_isCalBgImage(ev.color)) return ''; 414 const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'"); 415 return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`; 416} 417 418function _todayCount() { 419 const t = _today(); 420 return _events.filter(e => { 421 if (!_eventVisible(e)) return false; 422 if (e.all_day) { 423 if (e.dtstart === e.dtend) return e.dtstart === t; 424 return e.dtstart <= t && e.dtend > t; 425 } 426 return _localDateOf(e.dtstart) === t; 427 }).length; 428} 429 430// Per-event â® menu: Remind me / Delete 431function _wireQuickDelete(body) { 432 body.querySelectorAll('.cal-event-more').forEach(btn => { 433 btn.addEventListener('click', (e) => { 434 e.stopPropagation(); 435 const uid = btn.dataset.uid; 436 if (!uid) return; 437 const ev = _allEvents[uid]; 438 if (!ev) return; 439 _showEventMoreMenu(ev, btn); 440 }); 441 }); 442} 443 444function _clampDropdown(dropdown, anchorRect) { 445 const margin = 8; 446 const vw = window.innerWidth; 447 const vh = window.innerHeight; 448 const r = dropdown.getBoundingClientRect(); 449 const w = r.width, h = r.height; 450 // Horizontal: prefer right-aligned with anchor, clamp to viewport 451 let left = anchorRect.right - w; 452 if (left + w > vw - margin) left = vw - margin - w; 453 if (left < margin) left = margin; 454 // Vertical: below anchor if it fits, else above 455 let top = anchorRect.bottom + 4; 456 if (top + h > vh - margin) { 457 const above = anchorRect.top - 4 - h; 458 top = above >= margin ? above : Math.max(margin, vh - margin - h); 459 } 460 dropdown.style.left = `${left}px`; 461 dropdown.style.top = `${top}px`; 462 dropdown.style.right = 'auto'; 463} 464 465function _showEventMoreMenu(ev, anchor) { 466 document.querySelectorAll('.cal-event-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); 467 const dropdown = document.createElement('div'); 468 dropdown.className = 'cal-event-dropdown'; 469 let closeMenu = () => dropdown.remove(); 470 const rect = anchor.getBoundingClientRect(); 471 dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; 472 473 const _item = (icon, label, onClick, danger) => { 474 const it = document.createElement('div'); 475 it.className = 'dropdown-item-compact' + (danger ? ' dropdown-item-danger' : ''); 476 it.innerHTML = `<span class="dropdown-icon">${icon}</span><span>${label}</span>`; 477 it.addEventListener('click', (e) => { e.stopPropagation(); onClick(); }); 478 return it; 479 }; 480 481 const _editIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'; 482 483 dropdown.appendChild(_item(_editIcon, 'Edit', () => { 484 closeMenu(); 485 _showEventForm(ev); 486 })); 487 488 dropdown.appendChild(_item(_trashIcon, 'Delete', async () => { 489 closeMenu(); 490 const name = ev.summary ? `"${ev.summary}"` : 'this event'; 491 const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); 492 if (!ok) return; 493 try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {} 494 }, true)); 495 496 document.body.appendChild(dropdown); 497 dropdown._anchorRect = rect; 498 _clampDropdown(dropdown, rect); 499 dropdown.style.visibility = ''; 500 closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);} 501 502async function _createEventReminder(ev, dueDate) { 503 // Store the reminder as an absolute UTC instant (with the Z suffix) so the 504 // notification poller fires at the right wall-clock moment regardless of: 505 // - the event's source timezone (CalDAV/import may carry a TZI
505D), 506 // - the user's current local timezone differing from when the reminder 507 // was created, 508 // - any naive ISO mis-interpretation downstream. 509 // Both notes.js and the calendar poller already use `new Date(due_date)`, 510 // which handles Z-suffixed ISO correctly and converts back to local time 511 // when displayed. 512 const iso = new Date(dueDate).toISOString(); 513 const startFmt = ev.all_day 514 ? new Date(ev.dtstart).toLocaleDateString([], { weekday:'short', month:'short', day:'numeric' }) 515 : new Date(ev.dtstart).toLocaleString([], { weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); 516 const summary = ev.summary || '(no title)'; 517 const loc = ev.location ? ` @ ${ev.location}` : ''; 518 const text = `${summary}${loc} â ${startFmt}`; 519 const payload = { 520 title: `Reminder: ${summary}`, 521 note_type: 'todo', 522 items: [{ text, done: false, checked: false }], 523 label: 'calendar', 524 due_date: iso, 525 source: 'calendar', 526 // Persist the EVENT'S absolute start so the notification body can be 527 // computed live at fire time ("Starts in 5 min") instead of using a 528 // stale string baked at scheduling time. 529 event_dtstart: new Date(ev.dtstart).toISOString(), 530 }; 531 try { 532 const res = await fetch(`/api/notes`, { 533 method: 'POST', credentials: 'same-origin', 534 headers: { 'Content-Type': 'application/json' }, 535 body: JSON.stringify(payload), 536 }); 537 if (!res.ok) throw new Error('Failed'); 538 const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); 539 if (uiModule.showToast) uiModule.showToast(`Reminder set for ${fmt}`); 540 try { window.notesModule?.refreshDueBadge?.({ force: true }); } catch {} 541 if ('Notification' in window && Notification.permission === 'default') { 542 try { Notification.requestPermission(); } catch {} 543 } 544 } catch (e) { 545 if (uiModule.showError) uiModule.showError('Failed to create reminder'); 546 } 547} 548 549// ââ Sidebar collapse ââ 550 551function _collapseSidebar() { 552 const sb = document.getElementById('sidebar'); 553 if (sb && !sb.classList.contains('hidden')) { 554 // Only remember the prior state on desktop. On mobile the sidebar is an 555 // overlay that the user intentionally swipes/taps away when the tool 556 // opens â popping it back on close is unwanted. 557 if (window.innerWidth >= 700) _sidebarWasOpen = true; 558 sb.classList.add('hidden'); 559 if (window.syncRailSide) window.syncRailSide(); 560 } 561} 562 563function _restoreSidebar() { 564 if (_sidebarWasOpen) { 565 const sb = document.getElementById('sidebar'); 566 if (sb) { sb.classList.remove('hidden'); if (window.syncRailSide) window.syncRailSide(); } 567 _sidebarWasOpen = false; 568 } 569} 570 571// ââ Badge ââ 572 573const BADGE_SEEN_KEY = 'odysseus-calendar-badge-seen'; 574 575function _todayStr() { 576 const d = new Date(); 577 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 578} 579 580function _isBadgeSeenToday() { 581 try { return localStorage.getItem(BADGE_SEEN_KEY) === _todayStr(); } catch { return false; } 582} 583 584function _markBadgeSeen() { 585 try { localStorage.setItem(BADGE_SEEN_KEY, _todayStr()); } catch {} 586} 587 588function _updateBadge() { 589 const btn = document.getElementById('tool-calendar-btn'); 590 if (!btn) return; 591 let badge = btn.querySelector('.cal-badge'); 592 const count = _todayCount(); 593 if (count > 0 && !_isBadgeSeenToday()) { 594 if (!badge) { badge = document.createElement('span'); badge.className = 'cal-badge'; btn.appendChild(badge); } 595 badge.title = `${count} event${count > 1 ? 's' : ''} today`; 596 } else if (badge) badge.remove(); 597} 598 599// ââ Modal ââ 600 601function _getModal() { 602 if (_modal) return _modal; 603 _modal = document.createElement('div'); 604 _modal.id = 'calendar-modal'; 605 _modal.className = 'modal'; 606 _modal.style.display = 'none'; 607 _modal.innerHTML = ` 608 <div class="modal-content cal-modal-content"> 609 <div class="modal-header"> 610 <h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Calendar</h4> 611 <button class="close-btn" id="cal-close">â</button> 612 </div> 613 <div class="modal-body" id="cal-body"></div> 614 </div>`; 615 document.body.appendChild(_modal); 616 _modal.querySelector('#cal-close').addEventListener('click', closeCalendar); 617 _modal.addEventListener('click', (e) =>
617 { if (e.target === _modal) closeCalendar(); }); 618 // Make draggable â replaced ~50 lines of inline drag/dock plumbing with 619 // a single call to the shared helper. Calendar doesn't support fullscreen 620 // snap so no fsClass / enter/exit callbacks here. 621 { 622 const content = _modal.querySelector('.modal-content'); 623 const header = _modal.querySelector('.modal-header'); 624 if (content && header) { 625 makeWindowDraggable(_modal, { content, header }); 626 } 627 } 628 return _modal; 629} 630 631// ââ Render dispatch ââ 632 633// Stash the quick-add input's state (focus + caret + value) before a 634// re-render so background fetches don't kick the user out mid-type. Picked 635// up by _wireAll after the new DOM lands. 636let _qaPendingRestore = null; 637function _saveQuickAddState() { 638 const el = document.getElementById('cal-quickadd'); 639 if (!el || document.activeElement !== el) { _qaPendingRestore = null; return; } 640 _qaPendingRestore = { 641 value: el.value, 642 selStart: el.selectionStart, 643 selEnd: el.selectionEnd, 644 }; 645} 646 647// True while the user is actively in the quick-add field. On mobile a 648// programmatic re-focus after a DOM rebuild can't reopen the soft keyboard, so 649// we must NOT swap the calendar body out from under an active quick-add â we 650// defer the render and flush it on blur instead. 651let _renderPending = false; 652let _qaSubmitting = false; 653function _qaTyping() { 654 const el = document.getElementById('cal-quickadd'); 655 return !!el && document.activeElement === el; 656} 657 658// Update only the search-results portion of the day-detail panel, keeping 659// the search input element itself in the DOM so the on-screen keyboard 660// doesn't dismiss between keystrokes. Used by the search input's `input` 661// listener instead of a full _render(). 662function _updateDaySearchResults() { 663 const dayDetail = document.querySelector('.cal-day-detail'); 664 if (!dayDetail) { _render(); return; } 665 // Searching forces a selected day so the panel is always available 666 // (matches the logic in _render). 667 if (_searchQuery && !_selectedDay) _selectedDay = _today(); 668 const ds = _selectedDay || _today(); 669 // Build the day-detail HTML in a detached node so we can extract its 670 // children (results, header, etc.) without touching the live input. 671 const tmp = document.createElement('div'); 672 tmp.innerHTML = _dayDetailHTML(ds); 673 const fresh = tmp.querySelector('.cal-day-detail'); 674 if (!fresh) return; 675 // Remove every child of the live day-detail except the search-wrap. 676 const keep = dayDetail.querySelector('.cal-search-wrap'); 677 [...dayDetail.children].forEach(c => { if (c !== keep) c.remove(); }); 678 // Move children from the fresh build into the live panel, skipping 679 // the duplicate search-wrap. 680 [...fresh.children].forEach(c => { 681 if (!c.classList.contains('cal-search-wrap')) dayDetail.appendChild(c); 682 }); 683 // Re-wire click handlers on the newly-inserted event rows. 684 dayDetail.querySelectorAll('.cal-event-item').forEach(it => { 685 it.addEventListener('click', (e) => { 686 if (e.target.closest('.cal-event-more')) return; 687 const ev = _events.find(x => x.uid === it.dataset.uid); 688 if (ev) _showEventForm(ev); 689 }); 690 }); 691 dayDetail.querySelector('#cal-add-day')?.addEventListener('click', () => _showEventForm(null, _selectedDay)); 692 _wireQuickDelete(dayDetail); 693} 694 695// Step between calendar views by "zoom level" â pinch IN goes yearâmonthâweek, 696// pinch OUT goes the other way. Agenda is its own thing so it's excluded. 697function _zoomView(direction) { 698 const chain = ['year', 'month', 'week']; 699 const idx = chain.indexOf(_view); 700 if (idx < 0) return; 701 const next = idx + direction; 702 if (next < 0 || next >= chain.length) return; 703 _view = chain[next]; 704 _render(); 705} 706 707// Monotonic counter bumped on every _render() call. The async per-view 708// render functions snapshot this at entry and bail before painting DOM if 709// a newer render has already started. Stops fast prev/next/today clicks 710// from letting a slow fetch clobber the latest layout. 711let _renderToken = 0; 712function _isStaleRender(t) { return t !== _renderToken; } 713 714function _render() { 715 // Don't rebuild the DOM while the user is typing in quick-add â defer it. 716 if (_qaTyping()) { _renderPending = true; return; } 717 // Empty state: no calendars configured or connection failed 718 if (!_calendars.length) { 719 _renderEmpty(); 720 return; 721 } 722 _renderToken++; 723 // Search now lives inside the day-detail panel and filters in place, 724 // so we don't replace the whole calendar body when a query is active. 725 // Force a selected day in month/week so the panel (and its search box) 726 // is always available. 727 if (_searchQuery && (_view === 'month' || _view === 'week') && !_selectedDay) { 728 _selectedDay = _today(); 729 } 730 if (_view === 'agenda') _renderAgenda(); 731 else if (_view === 'year') _renderYear(); 732 else if (_view === 'week') _renderWeek(); 733 else _renderMonth(); 734 // Prefetch adjacent in background after a short delay 735 setTimeout(() => _prefetchAdjacent(), 200); 736} 737 738function _renderEmpty() { 739 const body = document.getElementById('cal-body'); 740 if (!body) return; 741 const hasError = !!_calendarsError; 742 body.innerHTML = ` 743 <div class="cal-empty-state"> 744 <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"> 745 <rect x="3" y="4" width="18" height="18" rx="2"/> 746 <line x1="16" y1="2" x2="16" y2="6"/> 747 <line x1="8" y1="2" x2="8" y2="6"/> 748 <line x1="3" y1="10" x2="21" y2="10"/> 749 </svg> 750 <div class="cal-empty-title">${hasError ? 'Calendar unavailable' : 'No calendars yet'}</div> 751 <div class="cal-empty-msg">${hasError ? _e(_calendarsError) : 'Create a local calendar, import an .ics file, or sync via CalDAV.'}</div> 752 ${hasError ? ` 753 <button class="cal-btn cal-btn-primary" id="cal-goto-settings">Open Settings</button> 754 ` : ` 755 <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:4px;"> 756 <button class="cal-btn cal-btn-primary" id="cal-empty-new">New calendar</button> 757 <button class="cal-btn" id="cal-empty-import">Import .ics</button> 758 </div> 759 <div style="margin-top:10px;font-size:11px;opacity:0.55;">Or <a href="#" id="cal-empty-caldav" style="color:var(--accent, var(--red));text-decoration:none;font-weight:600;">set up CalDAV sync</a>.</div> 760 `} 761 </div>`; 762 document.getElementById('cal-goto-settings')?.addEventListener('click', () => {
763 closeCalendar(); 764 const modal = document.getElementById('settings-modal'); 765 if (modal) { 766 modal.classList.remove('hidden'); 767 const tab = modal.querySelector('[data-settings-tab="integrations"]'); 768 if (tab) tab.click(); 769 } 770 }); 771 // New / Import open the calendar settings panel; the panel already 772 // has the "New calendar" button and the .ics file picker. Import 773 // triggers the file picker immediately so it's a one-click flow. 774 document.getElementById('cal-empty-new')?.addEventListener('click', () => { 775 _showCalSettings(); 776 setTimeout(() => document.getElementById('cal-settings-add')?.click(), 50); 777 }); 778 document.getElementById('cal-empty-import')?.addEventListener('click', () => { 779 _showCalSettings(); 780 setTimeout(() => document.getElementById('cal-import-file')?.click(), 50); 781 }); 782 document.getElementById('cal-empty-caldav')?.addEventListener('click', (e) => { 783 e.preventDefault(); 784 closeCalendar(); 785 // Integrations is an admin tab â settingsModule.open() only sets 786 // the .active class for admin tabs; the actual panel renders via 787 // adminModule.open(). Without the admin-first branch the modal 788 // appears with Integrations highlighted but showing the previous 789 // panel, so the user has to click the tab again to land there. 790 if (window.adminModule && typeof window.adminModule.open === 'function') { 791 try { window.adminModule.open('integrations'); return; } catch (_) {} 792 } 793 if (window.settingsModule && typeof window.settingsModule.open === 'function') { 794 try { window.settingsModule.open('integrations'); return; } catch (_) {} 795 } 796 const modal = document.getElementById('settings-modal'); 797 if (modal) { 798 modal.classList.remove('hidden'); 799 const tab = modal.querySelector('[data-settings-tab="integrations"]'); 800 if (tab) tab.click(); 801 } 802 }); 803} 804 805// ââ Header + Filters (shared) ââ 806 807function _isoWeekNumber(d) { 808 // ISO 8601: weeks start Monday; week 1 contains the year's first Thursday. 809 const tgt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 810 // Move to Thursday of this week (so the year is determined correctly). 811 tgt.setDate(tgt.getDate() + 3 - ((tgt.getDay() + 6) % 7)); 812 const yearStart = new Date(tgt.getFullYear(), 0, 1); 813 return Math.ceil(((tgt - yearStart) / 86400000 + 1) / 7); 814} 815 816function _headerHTML() { 817 const weekSuffix = _view === 'week' 818 ? ` <span class="cal-week-no">W${_isoWeekNumber(_currentDate)}</span>` 819 : ''; 820 return `<div class="cal-toolbar"> 821 <div class="cal-toolbar-nav"> 822 <button class="cal-nav" id="cal-prev">←</button> 823 <button class="cal-nav cal-today-btn" id="cal-today">Today</button> 824 <span class="cal-title">${_view === 'agenda' ? 'Upcoming' : MONTHS[_currentDate.getMonth()] + ' ' + _currentDate.getFullYear()}${weekSuffix}</span> 825 <button class="cal-nav" id="cal-next">→</button> 826 </div> 827 <div class="cal-toolbar-right"> 828 <div class="cal-view-toggle"> 829 ${['week', 'month', 'year', 'agenda'].map(v => 830 `<button class="cal-view-btn${_view === v ? ' active' : ''}" data-view="${v}">${v[0].toUpperCase() + v.slice(1)}</button>` 831 ).join('')} 832 </div> 833 <button class="cal-nav" id="cal-settings" title="Calendar settings" style="position:relative;top:-3px;"><svg width="13" height="13" style="position:relative;top:2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.68 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button> 834 <button class="cal-nav${window._calSyncing ? ' cal-syncing' : ''}${window._calSyncDone ? ' cal-sync-done' : ''}" id="cal-sync" title="Refresh from database" style="position:relative;top:-3px;">${window._calSyncDone ? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' : '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>'}</button> 835 ${_filtersToggleHTML()} 836 <button class="cal-add-btn cal-add-btn-text" id="cal-add" title="New event"><span class="cal-add-plus">+</span><span class="cal-add-label">New</span></button> 837 </div> 838 </div> 839 <div class="cal-quickadd-row" id="cal-quickadd-row"> 840 <input 841 type="text" 842 id="cal-quickadd" 843 class="cal-quickadd-input" 844 placeholder=" " 845 autocomplete="off" 846 /> 847 <span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> â return home to Ithaca 1pm tmrw <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span> 848 <span class="cal-quickadd-status" id="cal-quickadd-status"></span> 849 </div>`; 850} 851 852function _filtersData() { 853 // Build chip HTML once; reused by toolbar toggle + chip-row renderers. 854 let calFilters = ''; 855 if (_calendars.length > 1) { 856 calFilters = _calendars.map(c => { 857 const off = _hiddenCals.has(c.href); 858 return `<label class="cal-filter-item${off ? ' cal-filter-off' : ''}" data-href="${_e(c.href)}">
859 <span class="cal-filter-dot" style="background:${c.color}"></span>${_e(c.name)}</label>`; 860 }).join(''); 861 } 862 const presentTypes = new Set(_events.map(e => e.event_type).filter(Boolean)); 863 const hasUntagged = _events.some(e => !e.event_type); 864 const hasImportant = _events.some(e => e.importance === 'high' || e.importance === 'critical'); 865 if (hasImportant) presentTypes.add('!'); 866 const typeOrder = ['!', 'work', 'personal', 'health', 'travel', 'meal', 'social', 'admin', 'other']; 867 let typeFilters = ''; 868 for (const t of typeOrder) { 869 if (!presentTypes.has(t)) continue; 870 const off = (t === '!') ? false : _hiddenTypes.has(t); 871 const active = (t === '!') && _onlyImportant; 872 const label = t === '!' ? '! important' : t; 873 typeFilters += `<label class="cal-filter-item${off ? ' cal-filter-off' : ''}${active ? ' cal-filter-active' : ''}${t === '!' ? ' cal-filter-important' : ''}" data-type="${t}"> 874 <span class="cal-filter-dot" style="background:${_TYPE_PALETTE[t]}"></span>${label}</label>`; 875 } 876 if (hasUntagged) { 877 const off = _hiddenTypes.has('__untagged__'); 878 typeFilters += `<label class="cal-filter-item${off ? ' cal-filter-off' : ''}" data-type="__untagged__"> 879 <span class="cal-filter-dot" style="background:${_TYPE_PALETTE.untagged}"></span>untagged</label>`; 880 } 881 return { calFilters, typeFilters }; 882} 883 884function _filtersToggleHTML() { 885 // Inline toolbar button only. The chip row renders separately below. 886 const { calFilters, typeFilters } = _filtersData(); 887 if (!calFilters && !typeFilters) return ''; 888 return `<button class="cal-filter-toggle" id="cal-filter-toggle" title="${_filtersCollapsed ? 'Show filters' : 'Hide filters'}">${_filtersCollapsed ? '+ tags' : 'â tags'}</button>`; 889} 890 891function _filtersRowHTML() { 892 // Chip row beneath the toolbar â empty when collapsed. 893 if (_filtersCollapsed) return ''; 894 const { calFilters, typeFilters } = _filtersData(); 895 if (!calFilters && !typeFilters) return ''; 896 const sep = (calFilters && typeFilters) ? '<span style="opacity:0.3;margin:0 4px">·</span>' : ''; 897 return `<div class="cal-filters">${calFilters}${sep}${typeFilters}</div>`; 898} 899 900function _eventVisible(e) { 901 if (_hiddenCals.has(e.calendar_href)) return false; 902 // "Only important" mode short-circuits category filters: nothing else 903 // matters except whether the event itself is high/critical. 904 if (_onlyImportant) { 905 return e.importance === 'high' || e.importance === 'critical'; 906 } 907 if (e.event_type) { 908 if (_hiddenTypes.has(e.event_type)) return false; 909 } else if (_hiddenTypes.has('__untagged__')) { 910 return false; 911 } 912 return true; 913} 914 915// ââ Month View ââ 916 917async function _renderMonth() { 918 const body = document.getElementById('cal-body'); 919 if (!body) return; 920 const _tk = _renderToken; 921 const [rs, re] = _monthRange(_currentDate); 922 await _fetchEvents(rs, re); 923 if (_isStaleRender(_tk)) return; // newer render already in flight 924 const today = _today(); 925 const y = _currentDate.getFullYear(), m = _currentDate.getMonth(); 926 927 const slideClass = _slideDir > 0 ? ' cal-slide-in-right' : _slideDir < 0 ? ' cal-slide-in-left' : ''; 928 _slideDir = 0; 929 let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`; 930 h += '<div class="cal-week-headers">'; 931 for (const wd of WEEKDAYS) h += `<div class="cal-weekday">${wd}</div>`; 932 h += '</div>'; 933 934 const first = new Date(y, m, 1); 935 const dow = (first.getDay() + 6) % 7; 936 const gs = new Date(y, m, 1 - dow); 937 938 const multiDay = _events.filter(e => { 939 if (!_eventVisible(e)) return false; 940 const startD = new Date(e.dtstart), endD = new Date(e.dtend); 941 return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend)); 942 }); 943 const multiUids = new Set(multiDay.map(e => e.uid)); 944 945 // Render 6 week rows. Each row is a positioned container that holds 946 // 7 day cells AND any multi-day bars that span the row, drawn as an 947 // absolute overlay on top of the cells. This avoids the old "each 948 // bar lives inside its start cell and gets clipped at the cell edge" 949 // problem so a multi-day event reads as one continuous line across 950 // every day it covers. 951 for (let row = 0; row < 6; row++) { 952 // Count how many multi-day bars overlap any column in this row so 953 // cells can reserve top padding for them â otherwise the bars 954 // (drawn as absolute overlays) sit on top of the day-number and 955 // single-event rows below. 956 const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7); 957 const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6); 958 const rowStart0 = _ds(rowStartCd0); 959 const rowEnd0 = _ds(rowEndCd0); 960 const barsInRow = multiDay.filter(md => { 961 const mdStart = _localDateOf(md.dtstart); 962 const mdEnd = _localDateOf(md.dtend); 963 return !(mdEnd < rowStart0 || mdStart > rowEnd0); 964 }).length; 965 h += `<div class="cal-week-row" style="--bars:${barsInRow}">`; 966 // Day cells for this row 967 for (let col = 0; col < 7; col++) { 968 const i = row * 7 + col; 969 const cd = new Date(gs); cd.setDate(gs.getDate() + i); 970 const d = _ds(cd); 971 const isOther = cd.getMonth() !== m; 972 const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : ''); 973 h += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
974 // Single events â show up to 3 inline rows (multi-day events are 975 // drawn separately as an overlay below). 976 const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid)); 977 if (singles.length) { 978 const maxInline = window.innerWidth <= 768 ? 2 : 3; 979 const showInline = singles.slice(0, maxInline); 980 for (const ev of showInline) { 981 const t = ev.all_day ? '' : _fmtTime(ev.dtstart); 982 const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>' 983 : ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : ''; 984 const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : ''; 985 h += `<div class="cal-event-row" draggable="true" data-uid="${_e(ev.uid)}" title="${_e(ev.summary)}${ev.event_type ? ' · ' + ev.event_type : ''}${ev.importance && ev.importance !== 'normal' ? ' · ' + ev.importance : ''}"> 986 <span class="cal-event-row-dot" style="background:${_calColor(ev)}"></span> 987 ${_typeBadge} 988 ${t ? `<span class="cal-event-row-time">${t}</span>` : ''} 989 <span class="cal-event-row-name">${_impMark}${_e(ev.summary)}</span> 990 </div>`; 991 } 992 if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.length - maxInline} more</div>`; 993 } 994 h += '</div>'; 995 } 996 // Multi-day overlay bars for this row. Stack each bar one slot below 997 // the previous so two events on the same row don't overlap. 998 let barSlot = 0; 999 for (const md of multiDay) { 1000 const mdStart = _localDateOf(md.dtstart); 1001 const mdEnd = _localDateOf(md.dtend); 1002 // Compute the row's date range 1003 const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7); 1004 const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6); 1005 const rowStart = _ds(rowStartCd); 1006 const rowEnd = _ds(rowEndCd); 1007 if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row 1008 // Column within the row where the bar starts and how many days it spans 1009 const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000); 1010 const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000); 1011 const startColInt = Math.round(startCol); 1012 const endColInt = Math.round(endCol); 1013 const span = endColInt - startColInt + 1; 1014 // Proportional offsets for timed events that span across midnight 1015 // (e.g. 8 PM Mon â 5 AM Tue). Without this, an overnight serve 1016 // window visually fills the ENTIRE next day even when it only 1017 // covers a few hours. All-day events keep the full-day shape. 1018 // Bar visually spans from column (col+startFrac) to (col+span-1+endFrac), 1019 // so a 8 PMâ5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%. 1020 let startFrac = 0; 1021 let endFrac = 1; 1022 if (!md.all_day) { 1023 try { 1024 const sIso = md.dtstart || ''; 1025 const eIso = md.dtend || ''; 1026 const sDate = sIso ? new Date(sIso) : null; 1027 const eDate = eIso ? new Date(eIso) : null; 1028 // First-visible-day fraction (0 = midnight start). Clamp to 0 1029 // when the event started before this row, so the bar still 1030 // starts at the row's left edge. 1031 if (sDate && !isNaN(sDate) && mdStart >= rowStart) { 1032 const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0); 1033 startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000)); 1034 } 1035 if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) { 1036 const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0); 1037 endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000)); 1038 // CalDAV end-times are exclusive: an event ending at exactly 1039 // 00:00 on day N really ended at end-of-day N-1, so endFrac=0 1040 // would visually paint a zero-width slice. Snap to a small 1041 // visible minimum (5% of a day) so the bar still registers. 1042 if (endFrac === 0) endFrac = 1; 1043 } 1044 } catch (_) { startFrac = 0; endFrac = 1; } 1045 } 1046 h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`; 1047 barSlot++; 1048 } 1049 h += '</div>'; 1050 } 1051 h += '</div>'; 1052 if (_selectedDay) h += _dayDetailHTML(_selectedDay); 1053 // Capture the grid's scroll position before innerHTML wipes it â 1054 // selecting a day shouldn't jump the user back to the top of the 1055 // month, that hides the row they just clicked. 1056 const _prevGrid = body.querySelector('.cal-grid'); 1057 const _prevScroll = _prevGrid ? _prevGrid.scrollTop : 0; 1058 // If the user grabbed the quick-add field mid-fetch, skip the swap (which 1059 // would destroy the focused input + drop the keyboard) and defer until blur. 1060 if (_qaTyping()) { _renderPending = true; return; } 1061 body.innerHTML = h; 1062 const _newGrid = body.querySelector('.cal-grid'); 1063 if (_newGrid && _prevScroll) _newGrid.scrollTop = _prevScroll; 1064 // On open, scroll today's cell into view so the current date is always 1065 // visible even when its row sits below the fold (mobile scrolls the grid). 1066 if (_scrollToTodayOnOpen) { 1067 _scrollToTodayOnOpen = false;
1068 const todayCell = body.querySelector('.cal-day.cal-today'); 1069 if (todayCell && _newGrid) { 1070 requestAnimationFrame(() => { 1071 try { todayCell.scrollIntoView({ block: 'center', behavior: 'auto' }); } 1072 catch { _newGrid.scrollTop = Math.max(0, todayCell.offsetTop - _newGrid.clientHeight / 2); } 1073 }); 1074 } 1075 } 1076 _wireAll(body); 1077 _updateBadge(); 1078} 1079 1080// ââ Week View ââ 1081 1082// Hour-grid week view. Each column is a day; a vertical hour rail on the 1083// left labels 6amâ11pm. Events render as absolute-positioned blocks. 1084// Drag on an empty cell to scaffold a new event for that range. 1085// Render the full 24-hour day so events at any hour are reachable. 1086// On first open the grid auto-scrolls to ~7 AM so the default landing 1087// still matches the old "morning is visible" behaviour; subsequent 1088// renders preserve whatever scrollTop the user is on. 1089const WEEK_HOUR_START = 0; 1090const WEEK_HOUR_END = 24; 1091const WK_DEFAULT_SCROLL_HOUR = 7; 1092let _wkScrollY = null; // remembered scroll position across renders 1093let _wkScrolledOnce = false; // tracks the first auto-scroll-to-morning 1094// pixel height per hour â user-zoomable, persisted in localStorage so the 1095// preference sticks across reloads. Bounds keep the layout sane. 1096const WK_PX_MIN = 28; 1097const WK_PX_MAX = 120; 1098const WK_PX_DEFAULT = 64; 1099let WEEK_HOUR_PX = (() => { 1100 const saved = parseInt(localStorage.getItem('cal-wk-hour-px') || '', 10); 1101 return (saved >= WK_PX_MIN && saved <= WK_PX_MAX) ? saved : WK_PX_DEFAULT; 1102})(); 1103function _wkSetZoom(px) { 1104 // Capture the hour currently at the top of the viewport so the same 1105 // hour stays put across the zoom-induced re-render â otherwise the 1106 // saved pixel scrollTop misaligns at the new px/hour. 1107 const wrap = document.querySelector('.cal-wk-wrap'); 1108 let _hourAtTop = null; 1109 if (wrap && WEEK_HOUR_PX) _hourAtTop = wrap.scrollTop / WEEK_HOUR_PX; 1110 WEEK_HOUR_PX = Math.max(WK_PX_MIN, Math.min(WK_PX_MAX, Math.round(px))); 1111 try { localStorage.setItem('cal-wk-hour-px', String(WEEK_HOUR_PX)); } catch {} 1112 if (_hourAtTop != null) _wkScrollY = Math.round(_hourAtTop * WEEK_HOUR_PX); 1113 if (_view === 'week') _render(); 1114} 1115function _wkZoomBy(delta) { _wkSetZoom(WEEK_HOUR_PX + delta); } 1116function _wkHours() { return WEEK_HOUR_END - WEEK_HOUR_START; } 1117 1118// Round a Y offset (px from top of grid) to the nearest 15-minute slot, 1119// returns minutes-from-WEEK_HOUR_START. 1120function _wkPxToMin(y) { 1121 const totalMin = (y / WEEK_HOUR_PX) * 60; 1122 return Math.max(0, Math.round(totalMin / 15) * 15); 1123} 1124function _wkMinToHHMM(mins) { 1125 const t = WEEK_HOUR_START * 60 + mins; 1126 const h = Math.floor(t / 60), m = t % 60; 1127 return `${String(h).padStart(2, '0')}
1127:${String(m).padStart(2, '0')}`; 1128} 1129function _wkFormatHourLabel(h) { 1130 const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); 1131 if (!use12) return `${String(h).padStart(2, '0')}:00`; 1132 const ampm = h < 12 ? 'AM' : 'PM'; 1133 const hh = ((h + 11) % 12) + 1; 1134 return `${hh} ${ampm}`; 1135} 1136function _wkEventTopHeight(ev, dayStr) { 1137 // Convert event start/end (local) into top/height in px relative to the 1138 // day's grid origin. Clamp to visible window. 1139 // The dtstart/dtend strings are like "2026-05-11T09:00:00" (no tz), so 1140 // pull the time portion directly to avoid TZ math drift; falls back to 1141 // Date math if the string isn't shaped as expected. 1142 const _toMin = (iso, fallbackDate) => { 1143 if (!iso) return null; 1144 const m = iso.match(/T(\d{2}):(\d{2})/); 1145 if (m) { 1146 // If the event spans into a previous/next day, clamp to today's bounds. 1147 const evDate = iso.slice(0, 10); 1148 if (evDate < fallbackDate) return 0; // event started before today 1149 if (evDate > fallbackDate) return 24 * 60; // event ends after today 1150 return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); 1151 } 1152 // All-day or date-only â treat as start of day. 1153 return 0; 1154 }; 1155 const startMin = _toMin(ev.dtstart, dayStr); 1156 const endMin = _toMin(ev.dtend, dayStr) ?? (startMin + 60); 1157 const gridStart = WEEK_HOUR_START * 60; 1158 const gridEnd = WEEK_HOUR_END * 60; 1159 const sMin = Math.max(gridStart, startMin); 1160 const eMin = Math.min(gridEnd, Math.max(endMin, sMin + 15)); 1161 const top = (sMin - gridStart) * (WEEK_HOUR_PX / 60); 1162 const height = Math.max(18, (eMin - sMin) * (WEEK_HOUR_PX / 60)); 1163 return { top, height }; 1164} 1165 1166async function _renderWeek() { 1167 const body = document.getElementById('cal-body'); 1168 if (!body) return; 1169 const _tk = _renderToken; 1170 // Stash current scroll so we can restore after re-render (zoom, drag, 1171 // etc. all rebuild the body). 1172 const _prevWrap = body.querySelector('.cal-wk-wrap'); 1173 if (_prevWrap) _wkScrollY = _prevWrap.scrollTop; 1174 const [rs, re] = _weekRange(_currentDate); 1175 await _fetchEvents(rs, re); 1176 if (_isStaleRender(_tk)) return; 1177 const today = _today(); 1178 const ws = new Date(rs + 'T00:00:00'); 1179 1180 // Build day list once (used for both all-day strip and grid). 1181 const days = []; 1182 for (let i = 0; i < 7; i++) { 1183 const d = new Date(ws); d.setDate(ws.getDate() + i); 1184 days.push({ d, ds: _ds(d), idx: i }); 1185 } 1186 1187 // Hour rail on the left. The spacer up top hosts the zoom controls 1188 // (toolbar is already crowded â this empty 56-px corner is a free home). 1189 let railHtml = `<div class="cal-wk-rail"> 1190 <div class="cal-wk-rail-spacer"> 1191 <button class="cal-wk-zoom" id="cal-wk-zoom-out" title="Zoom out (â)" aria-label="Zoom out">â</button> 1192 <button class="cal-wk-zoom" id="cal-wk-zoom-in" title="Zoom in (+)" aria-label="Zoom in">+</button> 1193 </div>`; 1194 for (let h = WEEK_HOUR_START; h < WEEK_HOUR_END; h++) { 1195 railHtml += `<div class="cal-wk-rail-cell" style="height:${WEEK_HOUR_PX}px;"><span>${_wkFormatHourLabel(h)}</span></div>`; 1196 } 1197 railHtml += '</div>'; 1198 1199 // Day columns 1200 let colsHtml = '<div class="cal-wk-cols">'; 1201 for (const { d, ds, idx } of days) { 1202 const isToday = ds === today; 1203 const allDayEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && e.all_day); 1204 const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day); 1205 1206 const isSun = d.getDay() === 0; 1207 colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`; 1208 colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${WEEKDAYS[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`; 1209 // All-day strip 1210 colsHtml += `<div class="cal-wk-allday">`; 1211 for (const ev of allDayEvents) { 1212 colsHtml += `<div class="cal-wk-allday-event" data-uid="${_e(ev.uid)}" style="background:${_calColor(ev)};--cal-event-fg:${_calEventFg(ev)};" title="${_e(ev.summary)}">${_e(ev.summary)}</div>`; 1213 } 1214 colsHtml += `</div>`; 1215 // Hour-grid body 1216 colsHtml += `<div class="cal-wk-grid" data-date="${ds}" style="height:${_wkHours() * WEEK_HOUR_PX}px;">`; 1217 // Hour cell lines 1218 for (let h = WEEK_HOUR_START; h < WEEK_HOUR_END; h++) {
1219 colsHtml += `<div class="cal-wk-cell" data-hour="${h}" style="height:${WEEK_HOUR_PX}px;"></div>`; 1220 } 1221 // Now-line indicator (only on today) 1222 if (isToday) { 1223 const now = new Date(); 1224 const minSinceStart = (now.getHours() - WEEK_HOUR_START) * 60 + now.getMinutes(); 1225 if (minSinceStart >= 0 && minSinceStart <= _wkHours() * 60) { 1226 const top = minSinceStart * (WEEK_HOUR_PX / 60); 1227 colsHtml += `<div class="cal-wk-now" style="top:${top}px;"></div>`; 1228 } 1229 } 1230 // Timed event blocks. Each block carries a 6-px bottom-edge handle 1231 // for drag-to-resize (extend duration without opening the form). 1232 for (const ev of timedEvents) { 1233 const { top, height } = _wkEventTopHeight(ev, ds); 1234 const t = _fmtTime(ev.dtstart) + 'â' + _fmtTime(ev.dtend); 1235 // Custom-bg events get the image as the tile background; solid-color 1236 // events keep the original tinted treatment. 1237 let bgDecl; 1238 if (_isCalBgImage(ev.color)) { 1239 const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'"); 1240 bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`; 1241 } else { 1242 bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`; 1243 } 1244 colsHtml += `<div class="cal-wk-block" data-uid="${_e(ev.uid)}" style="top:${top}px;height:${height}px;border-left-color:${_calColor(ev)};${bgDecl}">`; 1245 colsHtml += `<div class="cal-wk-block-name">${_e(ev.summary)}</div>`; 1246 colsHtml += `<div class="cal-wk-block-time">${t}</div>`; 1247 colsHtml += `<div class="cal-wk-block-resize" title="Drag to resize"></div>`; 1248 colsHtml += `</div>`; 1249 } 1250 colsHtml += `</div></div>`; // /cal-wk-grid /cal-wk-col 1251 } 1252 colsHtml += '</div>'; 1253 1254 let h = _headerHTML() + _filtersRowHTML(); 1255 h += `<div class="cal-wk-wrap">${railHtml}${colsHtml}</div>`; 1256 if (_selectedDay) h += _dayDetailHTML(_selectedDay); 1257 // If the user grabbed the quick-add field mid-fetch, skip the swap (which 1258 // would destroy the focused input + drop the keyboard) and defer until blur. 1259 if (_qaTyping()) { _renderPending = true; return; } 1260 body.innerHTML = h; 1261 _wireAll(body); 1262 1263 // Single click (tap) an event block â open edit form. A drag-to-move or 1264 // drag-to-resize sets `justResized` in its mouseup so the trailing click 1265 // doesn't also open the form; the bottom-edge resize handle is ignored too. 1266 body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event').forEach(el => { 1267 el.addEventListener('click', (e) => { 1268 if (e.target.classList.contains('cal-wk-block-resize')) return; 1269 if (el.dataset.justResized) { delete el.dataset.justResized; return; } 1270 e.stopPropagation(); 1271 const ev = _events.find(x => x.uid === el.dataset.uid); 1272 if (ev) _showEventForm(ev); 1273 }); 1274 }); 1275 1276 // Drag the body of a block to reschedule (different day or time). The 1277 // bottom-edge handle has its own gesture (resize) and stops here, so 1278 // the two never fight. Same duration is preserved. 1279 body.querySelectorAll('.cal-wk-block').forEach(block => { 1280 block.addEventListener('mousedown', (e) => { 1281 if (e.button !== 0) return; 1282 if (e.target.classList.contains('cal-wk-block-resize')) return; // resize wins 1283 e.preventDefault(); 1284 const uid = block.dataset.uid; 1285 const ev = _events.find(x => x.uid === uid); 1286 if (!ev) return; 1287 const cols = Array.from(body.querySelectorAll('.cal-wk-grid')); 1288 if (!cols.length) return; 1289 // Original timing 1290 const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/); 1291 const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/); 1292 const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0; 1293 const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60; 1294 const durationMin = Math.max(15, endMin0 - startMin0); 1295 1296 // Where did the cursor grab the block? (offset from block-top in px) 1297 const blockRect = block.getBoundingClientRect(); 1298 const grabOffsetPx = e.clientY - blockRect.top; 1299 1300 // Ghost that follows the cursor across columns. 1301 const ghost = block.cloneNode(true); 1302 ghost.classList.add('cal-wk-block-ghost'); 1303 ghost.style.pointerEvents = 'none'; 1304 ghost.style.opacity = '0.85'; 1305 ghost.querySelector('.cal-wk-block-resize')?.remove(); 1306 // Mute the original while dragging. 1307 block.style.opacity = '0.25'; 1308 1309 let nextDs = null; 1310 let nextStartMin = startMin0; 1311 let activeGrid = null; 1312 let moved = false;
1313 const _attachGhost = (grid) => { 1314 if (activeGrid === grid) return; 1315 activeGrid = grid; 1316 grid.appendChild(ghost); 1317 }; 1318 const onMove = (mv) => { 1319 moved = true; 1320 // Pick the column under the cursor. If the cursor lands between 1321 // columns (gutter/border) or just outside the grid horizontally, 1322 // snap to the nearest column instead of giving up â that's why 1323 // horizontal cross-day drag could feel stuck before. 1324 let cur = cols.find(c => { 1325 const r = c.getBoundingClientRect(); 1326 return mv.clientX >= r.left && mv.clientX <= r.right; 1327 }); 1328 if (!cur) { 1329 let best = null, bestDist = Infinity; 1330 for (const c of cols) { 1331 const r = c.getBoundingClientRect(); 1332 const cx = (r.left + r.right) / 2; 1333 const d = Math.abs(mv.clientX - cx); 1334 if (d < bestDist) { bestDist = d; best = c; } 1335 } 1336 cur = best; 1337 } 1338 if (!cur) return; 1339 _attachGhost(cur); 1340 const r = cur.getBoundingClientRect(); 1341 const yIn = Math.max(0, Math.min(cur.clientHeight, mv.clientY - r.top)); 1342 // Subtract the grab offset so the cursor stays at the same spot 1343 // inside the block as you drag it around. 1344 const blockTopY = yIn - grabOffsetPx; 1345 const snapMin = Math.max(0, Math.round(_wkPxToMin(blockTopY) / 15) * 15); 1346 nextStartMin = WEEK_HOUR_START * 60 + snapMin; 1347 nextDs = cur.dataset.date; 1348 const top = (nextStartMin - WEEK_HOUR_START * 60) * (WEEK_HOUR_PX / 60); 1349 const height = durationMin * (WEEK_HOUR_PX / 60); 1350 ghost.style.top = top + 'px'; 1351 ghost.style.height = height + 'px'; 1352 const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0'); 1353 const mm = String(nextStartMin % 60).padStart(2, '0'); 1354 const hh2 = String(Math.floor((nextStartMin + durationMin) / 60)).padStart(2, '0'); 1355 const mm2 = String((nextStartMin + durationMin) % 60).padStart(2, '0'); 1356 const timeEl = ghost.querySelector('.cal-wk-block-time'); 1357 if (timeEl) timeEl.textContent = `${hh}:${mm}â${hh2}:${mm2}`; 1358 }; 1359 const onUp = async (up) => { 1360 document.removeEventListener('mousemove', onMove); 1361 document.removeEventListener('mouseup', onUp); 1362 ghost.remove(); 1363 block.style.opacity = ''; 1364 // Only suppress the trailing click-open if the user actually dragged â 1365 // a plain click (no movement) must still open the event. 1366 if (moved) block.dataset.justResized = '1'; 1367 // Decide whether anything actually moved. 1368 const oldDs = (ev.dtstart || '').slice(0, 10); 1369 if (!nextDs) return; 1370 if (nextDs === oldDs && nextStartMin === startMin0) return; 1371 // Snapshot the original times so we can offer an Undo. 1372 const prevDtstart = ev.dtstart; 1373 const prevDtend = ev.dtend; 1374 const newEndMin = nextStartMin + durationMin; 1375 const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0'); 1376 const mm = String(nextStartMin % 60).padStart(2, '0'); 1377 const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0'); 1378 const mm2 = String((newEndMin) % 60).padStart(2, '0'); 1379 const _tz = _tzOffset(); 1380 const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`; 1381 const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`; 1382 try { 1383 await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend }); 1384 _render(); 1385 _showCalUndoToast('Moved event', async () => { 1386 try { 1387 await _updateEvent(uid, { dtstart: prevDtstart, dtend: prevDtend }); 1388 _render(); 1389 } catch (err) { console.error('Undo failed:', err); } 1390 }); 1391 } catch { 1392 _render(); 1393 } 1394 }; 1395 document.addEventListener('mousemove', onMove); 1396 document.addEventListener('mouseup', onUp); 1397 }); 1398 }); 1399 1400 // Drag the bottom edge of a timed block to extend / shrink the event. 1401 // Snaps to 15-min increments; releases with a PUT to /api/calendar/events. 1402 body.querySelectorAll('.cal-wk-block .cal-wk-block-resize').forEach(handle => { 1403 handle.addEventListener('mousedown', (e) => { 1404 if (e.button !== 0) return; 1405 e.stopPropagation(); 1406 e.preventDefault(); 1407 const block = handle.closest('.cal-wk-block'); 1408 const grid = block.parentElement; 1409 const ds = grid.dataset.date; 1410 const uid = block.dataset.uid; 1411 const ev = _events.find(x => x.uid === uid); 1412 if (!ev || !grid || !ds) return; 1413 const startMin = (() => { 1414 const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/); 1415 return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0; 1416 })(); 1417 const initialTop = parseFloat(block.style.top || '0'); 1418 const gridRect = grid.getBoundingClientRect(); 1419 let newEndMin = startMin; 1420 let resized = false;
1421 const onMove = (mv) => { 1422 resized = true; 1423 const y = Math.max(0, Math.min(grid.clientHeight, mv.clientY - gridRect.top)); 1424 // Snap to 15-min increments; enforce a 15-min minimum duration. 1425 newEndMin = Math.max(startMin + 15, Math.round(_wkPxToMin(y) / 15) * 15); 1426 const newHeight = Math.max(18, (newEndMin - startMin) * (WEEK_HOUR_PX / 60)); 1427 block.style.height = newHeight + 'px'; 1428 const timeEl = block.querySelector('.cal-wk-block-time'); 1429 if (timeEl) { 1430 const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0'); 1431 const mm = String(newEndMin % 60).padStart(2, '0'); 1432 timeEl.textContent = `${_fmtTime(ev.dtstart)}â${hh}:${mm}`; 1433 } 1434 }; 1435 const onUp = async () => { 1436 document.removeEventListener('mousemove', onMove); 1437 document.removeEventListener('mouseup', onUp); 1438 if (resized) block.dataset.justResized = '1'; 1439 if (newEndMin === startMin) return; 1440 const prevDtend = ev.dtend; 1441 const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0'); 1442 const mm = String(newEndMin % 60).padStart(2, '0'); 1443 const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`; 1444 try { 1445 await _updateEvent(uid, { dtend: newDtend }); 1446 _render(); 1447 _showCalUndoToast('Resized event', async () => { 1448 try { 1449 await _updateEvent(uid, { dtend: prevDtend }); 1450 _render(); 1451 } catch (err) { console.error('Undo failed:', err); } 1452 }); 1453 } catch (err) { 1454 // Roll back the visual on failure 1455 _render(); 1456 } 1457 }; 1458 document.addEventListener('mousemove', onMove); 1459 document.addEventListener('mouseup', onUp); 1460 }); 1461 }); 1462 1463 // Drag-to-create on empty grid: mousedown on a cell, drag down, release. 1464 body.querySelectorAll('.cal-wk-grid').forEach(grid => { 1465 grid.addEventListener('mousedown', (e) => { 1466 // Don't start a drag-create when the press lands on an existing event. 1467 if (e.target.closest('.cal-wk-block')) return; 1468 if (e.button !== 0) return; 1469 e.preventDefault(); 1470 const rect = grid.getBoundingClientRect(); 1471 const ds = grid.dataset.date; 1472 const startY = e.clientY - rect.top; 1473 const ghost = document.createElement('div'); 1474 ghost.className = 'cal-wk-ghost'; 1475 grid.appendChild(ghost); 1476 const onMove = (mv) => { 1477 const y2 = Math.max(0, Math.min(grid.clientHeight, mv.clientY - rect.top)); 1478 const y1 = Math.min(startY, y2); 1479 const yEnd = Math.max(startY, y2); 1480 const startMin = _wkPxToMin(y1); 1481 const endMin = Math.max(_wkPxToMin(yEnd), startMin + 15); 1482 ghost.style.top = (startMin / 60) * WEEK_HOUR_PX + 'px'; 1483 ghost.style.height = ((endMin - startMin) / 60) * WEEK_HOUR_PX + 'px'; 1484 ghost.dataset.start = _wkMinToHHMM(startMin); 1485 ghost.dataset.end = _wkMinToHHMM(endMin); 1486 ghost.textContent = `${ghost.dataset.start} â ${ghost.dataset.end}`; 1487 }; 1488 const onUp = () => { 1489 document.removeEventListener('mousemove', onMove); 1490 document.removeEventListener('mouseup', onUp); 1491 const startHHMM = ghost.dataset.start; 1492 const endHHMM = ghost.dataset.end; 1493 ghost.remove(); 1494 if (!startHHMM || !endHHMM) return; 1495 // Open the bespoke event form pre-filled with this slot. 1496 _showEventFormForRange(ds, startHHMM, endHHMM); 1497 }; 1498 onMove(e); 1499 document.addEventListener('mousemove', onMove); 1500 document.addEventListener('mouseup', onUp); 1501 }); 1502 }); 1503 1504 // Restore scroll. Default-land at WK_DEFAULT_SCROLL_HOUR the first time 1505 // week view opens; afterwards keep the user's last position. 1506 const _wrap = body.querySelector('.cal-wk-wrap'); 1507 if (_wrap) { 1508 if (_wkScrollY != null) { 1509 _wrap.scrollTop = _wkScrollY; 1510 } else if (!_wkScrolledOnce) { 1511 _wrap.scrollTop = WK_DEFAULT_SCROLL_HOUR * WEEK_HOUR_PX; 1512 _wkScrolledOnce = true; 1513 } 1514 } 1515 1516 // Zoom buttons in the rail-spacer corner. 1517 document.getElementById('cal-wk-zoom-in')?.addEventListener('click', (e) => { e.stopPropagation(); _wkZoomBy(+12); }); 1518 document.getElementById('cal-wk-zoom-out')?.addEventListener('click', (e) => { e.stopPropagation(); _wkZoomBy(-12); }); 1519 1520 // Keyboard zoom (`+` / `-`), Ctrl/Cmd-wheel zoom â both only fire while 1521 // we're in week view and no text input has focus. 1522 if (!body._wkZoomKeysWired) { 1523 body._wkZoomKeysWired = true; 1524 document.addEventListener('keydown', (e) => { 1525 if (_view !== 'week') return; 1526 const tag = (document.activeElement?.tagName || '').toLowerCase(); 1527 if (tag === 'input' || tag === 'textarea' || tag === 'select') return; 1528 if (e.key === '+' || e.key === '=' ) { e.preventDefault(); _wkZoomBy(+12); } 1529 else if (e.key === '-' || e.key === '_') { e.preventDefault(); _wkZoomBy(-12); } 1530 else if (e.key === '0') { e.preventDefault(); _wkSetZoom(WK_PX_DEFAULT); } 1531 }); 1532 } 1533 body.querySelector('.cal-wk-wrap')?.addEventListener('wheel', (e) => { 1534 if (!(e.ctrlKey || e.metaKey)) return; 1535 e.preventDefault(); 1536 _wkZoomBy(e.deltaY < 0 ? +8 : -8); 1537 }, { passive: false }); 1538 1539 _updateBadge(); 1540} 1541 1542function _showEventFormForRange(ds, startHHMM, endHHMM) { 1543 // Open the new-event form, then seed the time inputs with the dragged 1544 // range and force the details panel open so the user can see/adjust. 1545 _showEventForm(null, ds, ds); 1546 requestAnimationFrame(() => { 1547 const startEl = document.getElementById('cal-f-start'); 1548 const endEl = document.getElementById('cal-f-end'); 1549 if (startEl) startEl.value = startHHMM; 1550 if (endEl) endEl.value = endHHMM; 1551 startEl?.dispatchEvent(new Event('input')); 1552 // Auto-expand details so the time fields are visible when someone 1553 // arrived here via drag-to-create rather than the +New button. 1554 document.querySelector('.cal-form-bespoke')?.classList.add('is-expanded'); 1555 const details = document.getElementById('cal-form-details'); 1556 if (details) details.setAttribute('aria-hidden', 'false'); 1557 }); 1558} 1559 1560// ââ Agenda View ââ 1561 1562async function _renderAgenda() { 1563 const body = document.getElementById('cal-body'); 1564 if (!body) return; 1565 const _tk = _renderToken; 1566 // Fetch 3 months forward from current date 1567 const s = _ds(_currentDate); 1568 const eDate = new Date(_currentDate); eDate.setMonth(eDate.getMonth() + 3); 1569 const e = _ds(eDate); 1570 await _fetchEvents(s, e); 1571 if (_isStaleRender(_tk)) return; 1572 1573 // Filter + group by date 1574 const visible = _events.filter(ev => !!_eventVisible(ev)) 1575 .sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); 1576 1577 let h = _headerHTML() + _filtersRowHTML() + '<div class="cal-agenda">'; 1578 // Group events by local date, then always surface today (when it's inside 1579 // the agenda window) even if it has no events, so the user can see "today". 1580 const byDate = new Map(); 1581 for (const ev of visible) { 1582 const d = _localDateOf(ev.dtstart); 1583 if (!byDate.has(d)) byDate.set(d, []); 1584 byDate.get(d).push(ev); 1585 } 1586 const today = _today(); 1587 if (today >= s && today <= e && !byDate.has(today)) byDate.set(today, []); 1588 const dates = [...byDate.keys()].sort(); 1589 1590 if (!dates.length) { 1591 // Empty-state mirrors the email panel: short message + a Settings ⺠1592 // Integrations link to set up CalDAV, OR a quick "Create event" action. 1593 h += '<div class="cal-empty" style="display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;">' + 1594 '<span>No upcoming events</span>' + 1595 '<span style="opacity:0.7;font-size:11px;">' + 1596 '<a href="#" data-cal-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings › Integrations</a>' + 1597 ' · ' +
1598 '<a href="#" data-cal-create-event="1" style="color:var(--accent,var(--red));text-decoration:underline;">Create event</a>' + 1599 '</span>' + 1600 '</div>'; 1601 } else { 1602 for (const date of dates) { 1603 const evs = byDate.get(date); 1604 const todayBadge = (date === today) ? ' <span class="cal-agenda-today-badge">Today</span>' : ''; 1605 h += `<div class="cal-agenda-day${date === today ? ' is-today' : ''}"><div class="cal-agenda-date">${_fmtDate(date)}${todayBadge}</div>`; 1606 if (!evs.length) { 1607 h += '<div class="cal-agenda-empty">No events</div>'; 1608 } 1609 for (const ev of evs) { 1610 const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' â ' + _fmtTime(ev.dtend); 1611 const _typeTag = ev.event_type 1612 ? `<span class="cal-event-tag" style="color:${_TYPE_PALETTE[ev.event_type] || _TYPE_PALETTE.other};border-color:${_TYPE_PALETTE[ev.event_type] || _TYPE_PALETTE.other}">#${_e(ev.event_type)}</span>` 1613 : ''; 1614 const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:4px" title="critical">!!</span>' 1615 : ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:4px" title="high">!</span>' : ''; 1616 h += `<div class="cal-agenda-event" data-uid="${_e(ev.uid)}"> 1617 <div class="cal-event-dot" style="background:${_calColor(ev)}"></div> 1618 <div class="cal-event-info"> 1619 <div class="cal-event-name">${_impMark}${_e(ev.summary)} ${_typeTag}</div> 1620 <div class="cal-event-time">${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}</div> 1621 </div> 1622 <button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button> 1623 </div>`; 1624 } 1625 h += '</div>'; 1626 } 1627 } 1628 h += '</div>'; 1629 // If the user grabbed the quick-add field mid-fetch, skip the swap (which 1630 // would destroy the focused input + drop the keyboard) and defer until blur. 1631 if (_qaTyping()) { _renderPending = true; return; } 1632 body.innerHTML = h; 1633 _wireAll(body); 1634 _wireQuickDelete(body); 1635 body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => { 1636 if (e.target.closest('.cal-event-more')) return; 1637 const ev = _events.find(e => e.uid === el.dataset.uid); 1638 if (ev) _showEventForm(ev); 1639 })); 1640 // Empty-state links: Settings ⺠Integrations + Create event. 1641 body.querySelector('[data-cal-open-settings]')?.addEventListener('click', (e) => { 1642 e.preventDefault(); 1643 closeCalendar(); 1644 const modal = document.getElementById('settings-modal'); 1645 if (modal) { 1646 modal.classList.remove('hidden'); 1647 const tab = modal.querySelector('[data-settings-tab="integrations"]'); 1648 if (tab) tab.click(); 1649 } 1650 }); 1651 body.querySelector('[data-cal-create-event]')?.addEventListener('click', (e) => { 1652 e.preventDefault(); 1653 _showEventForm(null); 1654 }); 1655 _updateBadge(); 1656} 1657 1658// ââ Search View ââ 1659 1660async function _renderSearch() { 1661 const body = document.getElementById('cal-body'); 1662 if (!body) return; 1663 // Search across all events in pool (no fetch needed â use what we have) 1664 const q = _searchQuery.toLowerCase(); 1665 const results = Object.values(_allEvents) 1666 .filter(ev => !!_eventVisible(ev)) 1667 .filter(ev => 1668 (ev.summary || '').toLowerCase().includes(q) || 1669 (ev.description || '').toLowerCase().includes(q) || 1670 (ev.location || '').toLowerCase().includes(q) 1671 ) 1672 .sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); 1673 1674 let h = _headerHTML() + _filtersRowHTML() + '<div class="cal-search-results">'; 1675 h += `<div class="cal-search-count">${results.length} result${results.length !== 1 ? 's' : ''} for "${_e(_searchQuery)}"</div>`; 1676 if (!results.length) { 1677 h += '<div class="cal-empty">No events match your search</div>'; 1678 } else { 1679 for (const ev of results) { 1680 const evDate = _localDateOf(ev.dtstart); 1681 const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' â ' + _fmtTime(ev.dtend); 1682 h += `<div class="cal-agenda-event" data-uid="${_e(ev.uid)}"> 1683 <div class="cal-event-dot" style="background:${_calColor(ev)}"></div> 1684 <div class="cal-event-info"> 1685 <div class="cal-event-name">${_e(ev.summary)}</div> 1686 <div class="cal-event-time">${_fmtDate(evDate)} · ${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}</div> 1687 </div> 1688 <button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button> 1689 </div>`; 1690 } 1691 } 1692 h += '</div>'; 1693 // If the user grabbed the quick-add field mid-fetch, skip the swap (which 1694 // would destroy the focused input + drop the keyboard) and defer until blur. 1695 if (_qaTyping()) { _renderPending = true; return; } 1696 body.innerHTML = h; 1697 _wireAll(body); 1698 _wireQuickDelete(body); 1699 body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => { 1700 if (e.target.closest('.cal-event-more')) return; 1701 const ev = _allEvents[el.dataset.uid]; 1702 if (ev) _showEventForm(ev); 1703 })); 1704 // Focus search input after re-render 1705 const searchInput = document.getElementById('cal-search'); 1706 if (searchInput && document.activeElement !== searchInput) { 1707 searchInput.focus(); 1708 searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length); 1709 } 1710} 1711 1712// ââ Year View ââ 1713 1714async function _renderYear() { 1715 const body = document.getElementById('cal-body'); 1716 if (!body) return; 1717 const _tk = _renderToken; 1718 const y = _currentDate.getFullYear(); 1719 await _fetchEvents(`${y}-01-01`, `${y + 1}-01-01`); 1720 if (_isStaleRender(_tk)) return; 1721 const today = _today(); 1722 1723 let h = _headerHTML() + _filtersRowHTML() + '<div class="cal-year">'; 1724 for (let m = 0; m < 12; m++) { 1725 h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`; 1726 h += '<div class="cal-year-grid">'; 1727 for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `<div class="cal-year-wd">${wd}</div>`; 1728 const first = new Date(y, m, 1); 1729 const dow = (first.getDay() + 6) % 7; 1730 const daysInMonth = new Date(y, m + 1, 0).getDate(); 1731 for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>'; 1732 for (let d = 1; d <= daysInMonth; d++) { 1733 const ds = `${y}
1733-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; 1734 const evs = _eventsForDay(ds); 1735 const isToday = ds === today; 1736 let cls = 'cal-year-cell cal-year-day'; 1737 if (isToday) cls += ' cal-year-today'; 1738 if (evs.length) cls += ' cal-year-has'; 1739 h += `<div class="${cls}" data-date="${ds}" title="${evs.length ? evs.length + ' event' + (evs.length > 1 ? 's' : '') : ''}">${d}</div>`; 1740 } 1741 h += '</div></div>'; 1742 } 1743 h += '</div>'; 1744 // If the user grabbed the quick-add field mid-fetch, skip the swap (which 1745 // would destroy the focused input + drop the keyboard) and defer until blur. 1746 if (_qaTyping()) { _renderPending = true; return; } 1747 body.innerHTML = h; 1748 _wireAll(body); 1749 // Month box click â jump to month view (but not when clicking a specific day) 1750 body.querySelectorAll('.cal-year-month').forEach(el => { 1751 el.addEventListener('click', (e) => { 1752 if (e.target.closest('.cal-year-day')) return; 1753 const m = parseInt(el.dataset.month); 1754 _currentDate = new Date(_currentDate.getFullYear(), m, 1); 1755 _view = 'month'; 1756 _render(); 1757 }); 1758 }); 1759 // Day click in year view â jump to month 1760 body.querySelectorAll('.cal-year-day').forEach(el => { 1761 el.addEventListener('click', () => { 1762 const d = el.dataset.date; 1763 _currentDate = new Date(d + 'T00:00:00'); 1764 _selectedDay = d; 1765 _view = 'month'; 1766 _render(); 1767 }); 1768 }); 1769 _updateBadge(); 1770} 1771 1772// ââ Shared HTML builders ââ 1773 1774function _dayDetailHTML(dateStr) { 1775 const isToday = dateStr === _today(); 1776 // Search lives inside the day panel now â typing filters the panel 1777 // body to global search results instead of just this day's events. 1778 // Magnifying-glass icon inside the search field via a wrapper + padding-left. 1779 const searchInput = `<div class="cal-search-wrap"> 1780 <svg class="cal-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg> 1781 <input type="search" class="cal-search-input cal-day-search" id="cal-search" placeholder="Search all eventsâ¦" value="${_e(_searchQuery)}" /> 1782 </div>`; 1783 let h = `<div class="cal-splitter" role="separator" aria-orientation="horizontal" tabindex="0" title="Drag to resize"><div class="cal-splitter-grip"></div></div> 1784 <div class="cal-day-detail"> 1785 ${searchInput} 1786 <div class="cal-detail-header"> 1787 <span>${_fmtDate(dateStr)}${isToday ? ' <span style="color:var(--accent, var(--red));font-weight:600;">(Today)</span>' : ''}</span> 1788 <button class="cal-add-btn cal-add-btn-text cal-add-btn-sm" id="cal-add-day" title="New event"><span class="cal-add-plus">+</span><span class="cal-add-label">New</span></button> 1789 </div>`; 1790 if (_searchQuery) { 1791 const q = _searchQuery.toLowerCase(); 1792 const results = _events 1793 .filter(_eventVisible) 1794 .filter(e => 1795 (e.summary || '').toLowerCase().includes(q) || 1796 (e.description || '').toLowerCase().includes(q) || 1797 (e.location || '').toLowerCase().includes(q) 1798 ) 1799 .sort((a, b) => (a.dtstart || '').localeCompare(b.dtstart || '')); 1800 h += `<div class="cal-day-search-meta">${results.length} result${results.length !== 1 ? 's' : ''}</div>`; 1801 if (!results.length) { 1802 h += '<div class="cal-empty">No events match</div>'; 1803 } else { 1804 results.forEach(ev => { 1805 const date = ev.all_day ? ev.dtstart : _localDateOf(ev.dtstart); 1806 const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' â ' + _fmtTime(ev.dtend); 1807 const bgStyle = _calItemBgStyle(ev); 1808 h += `<div class="cal-event-item${bgStyle ? ' cal-event-item-bg' : ''}" data-uid="${_e(ev.uid)}"${bgStyle ? ` style="${bgStyle}"` : ''}> 1809 <div class="cal-event-dot" style="background:${_calColor(ev)}"></div> 1810 <div class="cal-event-info"> 1811 <div class="cal-event-name">${_e(ev.summary)}</div> 1812 <div class="cal-event-time">${_fmtDate(date)} · ${t}</div> 1813 ${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location)}</div>` : ''} 1814 </div> 1815 <button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button> 1816 </div>`; 1817 }); 1818 } 1819 return h + '</div>'; 1820 } 1821 const evs = _eventsForDay(dateStr); 1822 if (!evs.length) h += '<div class="cal-empty">No events</div>';
1823 else evs.forEach(ev => { 1824 const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' â ' + _fmtTime(ev.dtend); 1825 const _bgStyle = _calItemBgStyle(ev); 1826 h += `<div class="cal-event-item${_bgStyle ? ' cal-event-item-bg' : ''}" data-uid="${_e(ev.uid)}"${_bgStyle ? ` style="${_bgStyle}"` : ''}><div class="cal-event-dot" style="background:${_calColor(ev)}"></div><div class="cal-event-info"><div class="cal-event-name">${_e(ev.summary)}</div><div class="cal-event-time">${t}</div>${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location)}</div>` : ''}</div><button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button></div>`; 1827 }); 1828 return h + '</div>'; 1829} 1830 1831// ââ Wire all common listeners ââ 1832 1833function _wireAll(body) { 1834 // ââ Day-detail splitter (drag to resize) ââââââââââââââââââââââââ 1835 // Restores the saved height each render so the user's choice survives 1836 // navigation between months/weeks. Drag adjusts a single CSS variable
1837 // on #cal-body â the grid clamps its height and the day-detail expands 1838 // / contracts accordingly via CSS rules. 1839 try { 1840 const calBody = document.getElementById('cal-body'); 1841 const splitter = body.querySelector('.cal-splitter'); 1842 if (calBody && splitter) { 1843 // Only seed from localStorage on the first wire-up. Subsequent 1844 // renders (every keystroke when the user is typing in search) 1845 // would otherwise clobber an in-progress focus-expand and bounce 1846 // the day-detail pane up and down on every character. 1847 const alreadySet = calBody.style.getPropertyValue('--cal-detail-h'); 1848 if (!alreadySet) { 1849 const saved = parseInt(localStorage.getItem('odysseus.cal.detailH') || '0', 10); 1850 if (saved && saved > 80) calBody.style.setProperty('--cal-detail-h', saved + 'px'); 1851 } 1852 let startY = 0, startH = 240, dragging = false; 1853 const onMove = (ev) => { 1854 if (!dragging) return; 1855 const y = ev.touches ? ev.touches[0].clientY : ev.clientY; 1856 // Drag UP (smaller y) â bigger day-detail. Allow the pane to grow 1857 // all the way to the top of the visible viewport so the user can 1858 // hide the calendar entirely. We leave ~24px headroom so the 1859 // splitter handle itself stays grabbable to drag back down. 1860 const vh = (window.visualViewport?.height) || window.innerHeight; 1861 const newH = Math.max(40, Math.min(vh - 24, startH + (startY - y))); 1862 calBody.style.setProperty('--cal-detail-h', newH + 'px'); 1863 }; 1864 const onUp = () => { 1865 if (!dragging) return; 1866 dragging = false; 1867 splitter.classList.remove('cal-splitter-dragging'); 1868 document.removeEventListener('pointermove', onMove); 1869 document.removeEventListener('pointerup', onUp); 1870 document.removeEventListener('touchmove', onMove); 1871 document.removeEventListener('touchend', onUp); 1872 const cur = calBody.style.getPropertyValue('--cal-detail-h'); 1873 const px = parseInt(cur, 10); 1874 if (px) { try { localStorage.setItem('odysseus.cal.detailH', String(px)); } catch {} } 1875 }; 1876 const onDown = (ev) => { 1877 ev.preventDefault(); 1878 dragging = true; 1879 splitter.classList.add('cal-splitter-dragging'); 1880 startY = ev.touches ? ev.touches[0].clientY : ev.clientY; 1881 const detail = body.querySelector('.cal-day-detail'); 1882 startH = detail ? detail.getBoundingClientRect().height : 240; 1883 document.addEventListener('pointermove', onMove); 1884 document.addEventListener('pointerup', onUp, { once: false }); 1885 document.addEventListener('touchmove', onMove, { passive: false }); 1886 document.addEventListener('touchend', onUp); 1887 }; 1888 splitter.addEventListener('pointerdown', onDown); 1889 splitter.addEventListener('touchstart', onDown, { passive: false }); 1890 1891 // Double-tap (or double-click) the splitter to reset the day-detail 1892 // pane to its CSS default height. 1893 let _lastTap = 0; 1894 const resetSplit = () => { 1895 calBody.style.removeProperty('--cal-detail-h'); 1896 try { localStorage.removeItem('odysseus.cal.detailH'); } catch {} 1897 }; 1898 splitter.addEventListener('dblclick', resetSplit); 1899 splitter.addEventListener('touchend', () => { 1900 const now = Date.now(); 1901 if (now - _lastTap < 320) { 1902 resetSplit(); 1903 _lastTap = 0; 1904 } else { 1905 _lastTap = now; 1906 } 1907 }); 1908 } 1909 } catch {} 1910 1911 // ââ Quick-add input âââââââââââââââââââââââââââââââââââââââââââââ 1912 const _qaInput = document.getElementById('cal-quickadd'); 1913 const _qaStatus = document.getElementById('cal-quickadd-status'); 1914 if (_qaInput && !_qaInput._wired) { 1915 _qaInput._wired = true; 1916 const _submitQA = async () => { 1917 const text = _qaInput.value.trim(); 1918 if (!text || _qaSubmitting) return; 1919 // Use a flag rather than `disabled` to block double-submit â disabling 1920 // the input blurs it, which would flush a deferred render and wipe the 1921 // spinner's container mid-parse. 1922 _qaSubmitting = true; 1923 // Whirlpool spinner after the text â but only once parsing has run long 1924 // enough to be worth showing (~250ms), so fast parses don't flash it. 1925 let _qaSpin = null; 1926 let _qaSpinTimer = null; 1927 if (_qaStatus) { 1928 _qaStatus.textContent = ''; 1929 try { 1930 const sp = (await import('./spinner.js')).default; 1931 _qaSpinTimer = setTimeout(() => { 1932 _qaSpin = sp.createWhirlpool(14); 1933 _qaSpin.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;left:-2px;margin-left:4px;'; 1934 _qaStatus.appendChild(_qaSpin.element); 1935 }, 250); 1936 } catch { 1937 _qaSpinTimer = setTimeout(() => { if (_qaStatus) _qaStatus.textContent = 'parsingâ¦'; }, 250); 1938 } 1939 }
1940 try { 1941 const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; 1942 const tzOffset = -new Date().getTimezoneOffset(); 1943 const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, { 1944 method: 'POST', 1945 credentials: 'same-origin', 1946 headers: { 'Content-Type': 'application/json' }, 1947 body: JSON.stringify({ text, tz, tz_offset: tzOffset }), 1948 }); 1949 const data = await res.json().catch(() => ({})); 1950 if (!res.ok || !data.ok) { 1951 if (_qaStatus) _qaStatus.textContent = ''; 1952 uiModule.showError('Quick-add: ' + (data.error || data.detail || `HTTP ${res.status}`)); 1953 return; 1954 } 1955 // Open the bespoke event form, then push the parsed fields in. 1956 const ev = data.event; 1957 const ds = (ev.dtstart || '').slice(0, 10); 1958 const de = (ev.dtend || '').slice(0, 10) || ds; 1959 _showEventForm(null, ds, de); 1960 requestAnimationFrame(() => { 1961 const set = (id, v) => { const el = document.getElementById(id); if (el && v != null) el.value = v; }; 1962 set('cal-f-sum', ev.summary); 1963 set('cal-f-loc', ev.location); 1964 set('cal-f-desc', ev.description); 1965 if (ev.all_day) { 1966 const ad = document.getElementById('cal-f-allday'); 1967 if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); } 1968 } else { 1969 const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/); 1970 const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/); 1971 if (t1) set('cal-f-start', t1[1]); 1972 if (t2) set('cal-f-end', t2[1]); 1973 document.getElementById('cal-f-start')?.dispatchEvent(new Event('input')); 1974 } 1975 // Make sure the details panel is open so the user can verify time. 1976 document.querySelector('.cal-form-bespoke')?.classList.add('is-expanded'); 1977 const det = document.getElementById('cal-form-details'); 1978 if (det) det.setAttribute('aria-hidden', 'false'); 1979 // Trigger Apple-Maps link sync now that location is filled in. 1980 document.getElementById('cal-f-loc')?.dispatchEvent(new Event('input')); 1981 }); 1982 // Reset for next quick add. 1983 _qaInput.value = ''; 1984 } catch (e) { 1985 uiModule.showError('Quick-add failed: ' + e.message); 1986 } finally { 1987 _qaSubmitting = false; 1988 clearTimeout(_qaSpinTimer); 1989 if (_qaSpin) { try { _qaSpin.destroy(); } catch {} _qaSpin.element?.remove(); } 1990 if (_qaStatus) _qaStatus.textContent = ''; 1991 } 1992 }; 1993 _qaInput.addEventListener('keydown', (e) => { 1994 if (e.key === 'Enter') { e.preventDefault(); _submitQA(); } 1995 else if (e.key === 'Escape') { _qaInput.value = ''; _qaInput.blur(); } 1996 }); 1997 // Flush any render we deferred while the field was focused. 1998 _qaInput.addEventListener('blur', () => { 1999 if (_renderPending) { _renderPending = false; _render(); } 2000 }); 2001 } 2002 // After a background re-render (e.g. /events fetch returning), restore 2003 // focus + caret + value so the user can keep typing uninterrupted. 2004 if (_qaInput && _qaPendingRestore) { 2005 _qaInput.value = _qaPendingRestore.value; 2006 _qaInput.focus(); 2007 try { 2008 _qaInput.setSelectionRange(_qaPendingRestore.selStart, _qaPendingRestore.selEnd); 2009 } catch {} 2010 _qaPendingRestore = null; 2011 } 2012 // Q anywhere on the page (when not typing elsewhere) focuses quick-add. 2013 if (!body._qaShortcutWired) { 2014 body._qaShortcutWired = true; 2015 document.addEventListener('keydown', (e) => { 2016 if (!_open) return; 2017 if (e.key !== 'q' && e.key !== 'Q') return; 2018 const tag = (document.activeElement?.tagName || '').toLowerCase(); 2019 if (tag === 'input' || tag === 'textarea' || tag === 'select') return; 2020 const inp = document.getElementById('cal-quickadd'); 2021 if (inp) { e.preventDefault(); inp.focus(); inp.select(); } 2022 }); 2023 } 2024 2025 // Pinch zoom on the calendar body changes the view granularity: 2026 // year â month â week. Pinch IN zooms to a tighter view, pinch OUT 2027 // zooms out. Fires once per gesture so a strong pinch doesn't skip 2028 // straight from year to week (the user gets one step at a time and 2029 // can release-and-pinch again). 2030 if (body && !body._pinchZoomWired) { 2031 body._pinchZoomWired = true; 2032 let pinchStart = 0, pinchActive = false, pinchFired = false;
2033 const dist = (ts) => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY); 2034 body.addEventListener('touchstart', (e) => { 2035 if (e.touches.length === 2) { 2036 pinchStart = dist(e.touches); 2037 pinchActive = true; 2038 pinchFired = false; 2039 } 2040 }, { passive: true }); 2041 body.addEventListener('touchmove', (e) => { 2042 if (!pinchActive || pinchFired || e.touches.length !== 2) return; 2043 const ratio = dist(e.touches) / pinchStart; 2044 if (ratio > 1.35) { _zoomView(+1); pinchFired = true; } 2045 else if (ratio < 0.7) { _zoomView(-1); pinchFired = true; } 2046 }, { passive: true }); 2047 body.addEventListener('touchend', (e) => { 2048 if (e.touches.length < 2) pinchActive = false; 2049 }, { passive: true }); 2050 } 2051 2052 // Touch swipe â â on the calendar body switches months/weeks/etc. Only 2053 // fires when the swipe is clearly horizontal so vertical scrolling inside 2054 // long event lists isn't hijacked. Attached fresh on each render via 2055 // _wireAll â existing prev/next handlers do the actual navigation. 2056 if (body && !body._swipeWired) { 2057 body._swipeWired = true; 2058 let _sx = 0, _sy = 0, _t0 = 0, _tracking = false; 2059 body.addEventListener('touchstart', (e) => { 2060 if (!e.touches || e.touches.length !== 1) return; 2061 _sx = e.touches[0].clientX; 2062 _sy = e.touches[0].clientY; 2063 _t0 = Date.now(); 2064 _tracking = true; 2065 }, { passive: true }); 2066 body.addEventListener('touchend', (e) => { 2067 if (!_tracking) return; 2068 _tracking = false; 2069 const t = e.changedTouches && e.changedTouches[0]; 2070 if (!t) return; 2071 const dx = t.clientX - _sx; 2072 const dy = t.clientY - _sy; 2073 const dt = Date.now() - _t0; 2074 // Threshold: at least 50px horizontal, dominant axis is horizontal, 2075 // and reasonably quick (under 600ms) so it feels intentional. 2076 if (Math.abs(dx) < 50) return; 2077 if (Math.abs(dx) < Math.abs(dy) * 1.3) return; 2078 if (dt > 600) return; 2079 if (dx < 0) document.getElementById('cal-next')?.click(); 2080 else document.getElementById('cal-prev')?.click(); 2081 }, { passive: true }); 2082 } 2083 2084 document.getElementById('cal-prev')?.addEventListener('click', () => { 2085 _slideDir = -1; 2086 if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() - 1, 0, 1); 2087 else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() - 7); 2088 else if (_view === 'agenda') _currentDate.setDate(_currentDate.getDate() - 30); 2089 else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() - 1, 1); 2090 // Keep a day selected in month/week so the day-detail panel â which hosts 2091 // the search box â stays available (otherwise browsing hides search). 2092 _selectedDay = (_view === 'month' || _view === 'week') ? _ds(_currentDate) : null; 2093 _render(); 2094 }); 2095 document.getElementById('cal-next')?.addEventListener('click', () => { 2096 _slideDir = 1; 2097 if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() + 1, 0, 1); 2098 else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() + 7); 2099 else if (_view === 'agenda') _currentDate.setDate(_currentDate.getDate() + 30); 2100 else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + 1, 1); 2101 _selectedDay = (_view === 'month' || _view === 'week') ? _ds(_currentDate) : null; 2102 _render(); 2103 }); 2104 document.getElementById('cal-today')?.addEventListener('click', () => { _currentDate = new Date(); _selectedDay = _today(); _render(); }); 2105 document.getElementById('cal-settings')?.addEventListener('click', () => _showCalSettings()); 2106 document.getElementById('cal-sync')?.addEventListener('click', async () => { 2107 // Visible feedback: toggle a CSS class on the button so the spin runs 2108 // even if the network round-trip is too fast to perceive. We hold it 2109 // for at least 700ms (one full rotation) AND for as long as the actual 2110 // fetch is in flight, then clear. Previously `await _render()` 2111 // resolved instantly because _render is synchronous, so the spinner 2112 // was setâcleared in the same tick and you saw nothing. 2113 const btn = document.getElementById('cal-sync'); 2114 btn?.classList.add('cal-syncing'); 2115 window._calSyncing = true; 2116 _allEvents = {}; 2117 _fetchedRanges = []; 2118 localStorage.removeItem(LS_KEY); 2119 2120 // Compute the visible range and force-refetch â _render() kicks off 2121 // a fetch internally but doesn't return a promise, so we await our 2122 // own one to actually serialize on the network. 2123 const _range = (_view === 'year') 2124 ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] 2125 : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate);
2126 const minSpin = new Promise(r => setTimeout(r, 700)); 2127 try { 2128 await Promise.all([ 2129 _fetchEvents(_range[0], _range[1], /*force*/ true).catch(() => {}), 2130 minSpin, 2131 ]); 2132 } finally { 2133 window._calSyncing = false; 2134 // Flash a checkmark for ~900ms. Drive it through a flag the toolbar 2135 // template reads (not a one-off innerHTML on the button), so a stray 2136 // _render() â the calendar re-renders mid-flow â can't wipe it. Same 2137 // reason the spin is flag-driven. 2138 window._calSyncDone = true; 2139 _render(); 2140 setTimeout(() => { 2141 window._calSyncDone = false; 2142 if (_open) _render(); 2143 }, 900); 2144 if (uiModule?.showToast) uiModule.showToast('Calendar refreshed'); 2145 } 2146 }); 2147 // Brief spin on the "+" glyph before the new-event form opens. The 2148 // glyph already rotates on hover (desktop). On mobile there's no 2149 // hover, so play the rotation on tap as a quick affordance. 2150 const _addClick = (e, openFn) => { 2151 if (window.innerWidth <= 768) { 2152 const plus = e.currentTarget.querySelector('.cal-add-plus'); 2153 if (plus) { 2154 plus.classList.add('cal-add-spinning'); 2155 setTimeout(() => plus.classList.remove('cal-add-spinning'), 360); 2156 } 2157 setTimeout(openFn, 220); 2158 } else { 2159 openFn(); 2160 } 2161 }; 2162 // If the user typed in quick-add but pressed "+ New" instead of Enter, treat 2163 // it as a quick-add (parse the text) rather than opening a blank event â a 2164 // common mix-up since the two controls sit side by side. 2165 const _tryQuickAddFromButton = () => { 2166 const qa = document.getElementById('cal-quickadd'); 2167 if (qa && qa.value.trim()) { 2168 qa.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); 2169 return true; 2170 } 2171 return false; 2172 }; 2173 document.getElementById('cal-add')?.addEventListener('click', (e) => _addClick(e, () => { if (!_tryQuickAddFromButton()) _showEventForm(null, _selectedDay || _today()); })); 2174 // Solo "+" on the day-detail header: no spin (the small round button 2175 // doesn't look good rotating in place â open the form immediately). 2176 document.getElementById('cal-add-day')?.addEventListener('click', () => { if (!_tryQuickAddFromButton()) _showEventForm(null, _selectedDay); }); 2177 2178 // Mobile: relocate the toolbar's +New pill so it sits NEXT TO the 2179 // quick-add row (not inside it â the row has its own border/background 2180 // that makes embedded buttons look like part of the input field). 2181 // Wrap the row and button in a flex container so they share one line. 2182 if (window.innerWidth <= 768) { 2183 const addBtn = document.getElementById('cal-add'); 2184 const qaRow = document.getElementById('cal-quickadd-row'); 2185 if (addBtn && qaRow) { 2186 let wrap = qaRow.parentElement; 2187 if (!wrap?.classList.contains('cal-quickadd-wrap')) { 2188 wrap = document.createElement('div'); 2189 wrap.className = 'cal-quickadd-wrap'; 2190 qaRow.parentElement?.insertBefore(wrap, qaRow); 2191 wrap.appendChild(qaRow); 2192 } 2193 if (addBtn.parentElement !== wrap) wrap.appendChild(addBtn); 2194 } 2195 } 2196 2197 // Search input â re-render rebuilds the day-detail DOM on each keystroke, 2198 // so refocus and restore caret position to keep typing smooth. 2199 const searchInput = document.getElementById('cal-search'); 2200 if (searchInput) { 2201 if (document.activeElement?.id === 'cal-search') { 2202 // First call after a re-render: refocus and place caret at end. 2203 searchInput.focus(); 2204 const len = searchInput.value.length;
2205 try { searchInput.setSelectionRange(len, len); } catch {} 2206 } 2207 searchInput.addEventListener('input', (e) => { 2208 _searchQuery = e.target.value.trim(); 2209 // Partial update: swap only the search results inside the day-detail 2210 // panel, leaving the search input element itself in place. A full 2211 // _render() destroys the input via innerHTML, and on iOS the 2212 // keyboard dismisses even if a brand-new input is focused 2213 // synchronously after. Keeping the same input element across 2214 // keystrokes is the only way to keep the keyboard up. 2215 _updateDaySearchResults(); 2216 }); 2217 // Mobile: when the search input gains focus the on-screen keyboard 2218 // pops up. Expand the day-detail pane to (near) the visible viewport 2219 // height so the search bar sits at the top of the screen, well above 2220 // the keyboard, instead of staying squashed behind it. 2221 searchInput.addEventListener('focus', () => { 2222 if (window.innerWidth > 768) return; 2223 const calBody = document.getElementById('cal-body'); 2224 if (!calBody) return; 2225 const vh = (window.visualViewport?.height) || window.innerHeight; 2226 const target = vh - 24; 2227 // Skip if already expanded â every keystroke triggers a re-render 2228 // which re-focuses the input. Re-running this on each keystroke 2229 // would shove the layout around as the user types. 2230 const cur = parseInt(calBody.style.getPropertyValue('--cal-detail-h'), 10) || 0; 2231 if (cur >= target - 24) return; 2232 calBody.style.setProperty('--cal-detail-h', target + 'px'); 2233 }); 2234 } 2235 2236 body.querySelectorAll('.cal-view-btn').forEach(b => b.addEventListener('click', () => { 2237 _view = b.dataset.view; 2238 _searchQuery = ''; 2239 _selectedDay = null; 2240 // Switching to Agenda always lands on today so you see "what's coming 2241 // up" rather than wherever you happened to be browsing. 2242 if (_view === 'agenda') _currentDate = new Date(); 2243 _render(); 2244 })); 2245 body.querySelector('#cal-filter-toggle')?.addEventListener('click', () => { 2246 _filtersCollapsed = !_filtersCollapsed; 2247 localStorage.setItem('cal-filters-collapsed', _filtersCollapsed ? '1' : '0'); 2248 _render(); 2249 }); 2250 body.querySelectorAll('.cal-filter-item').forEach(it => it.addEventListener('click', (e) => { 2251 const href = it.dataset.href; 2252 const type = it.dataset.type; 2253 if (href) { 2254 // Solo-filter: click = show only this calendar; click again = show all. 2255 // Shift/Ctrl+click = toggle individually (legacy hide/show). 2256 const allHrefs = Array.from(body.querySelectorAll('.cal-filter-item[data-href]')).map(el => el.dataset.href); 2257 if (e.shiftKey || e.ctrlKey || e.metaKey) { 2258 _hiddenCals.has(href) ? _hiddenCals.delete(href) : _hiddenCals.add(href); 2259 } else { 2260 const soloed = !_hiddenCals.has(href) && allHrefs.every(h => h === href || _hiddenCals.has(h)); 2261 if (soloed) { 2262 _hiddenCals.clear(); 2263 } else { 2264 _hiddenCals.clear(); 2265 allHrefs.forEach(h => { if (h !== href) _hiddenCals.add(h); }); 2266 } 2267 } 2268 } else if (type) { 2269 // "!" chip toggles a separate "only important" axis â clicking it 2270 // doesn't solo-hide other categories the way a normal type chip does. 2271 if (type === '!') { 2272 _onlyImportant = !_onlyImportant; 2273 // Clear category hides so importance becomes the active filter. 2274 if (_onlyImportant) _hiddenTypes.clear(); 2275 } else { 2276 const allTypes = Array.from(body.querySelectorAll('.cal-filter-item[data-type]')) 2277 .map(el => el.dataset.type) 2278 .filter(t => t !== '!'); 2279 // Engaging a category filter cancels "only important" so it doesn't 2280 // silently keep filtering on top. 2281 _onlyImportant = false; 2282 if (e.shiftKey || e.ctrlKey || e.metaKey) { 2283 _hiddenTypes.has(type) ? _hiddenTypes.delete(type) : _hiddenTypes.add(type); 2284 } else { 2285 const soloed = !_hiddenTypes.has(type) && allTypes.every(t => t === type || _hiddenTypes.has(t)); 2286 if (soloed) { 2287 _hiddenTypes.clear(); 2288 } else { 2289 _hiddenTypes.clear();
2290 allTypes.forEach(t => { if (t !== type) _hiddenTypes.add(t); }); 2291 } 2292 } 2293 } 2294 } 2295 _render(); 2296 })); 2297 body.querySelectorAll('.cal-day[data-date]').forEach(cell => cell.addEventListener('click', (e) => { 2298 if (e.target.closest('.cal-event-item,.cal-multiday')) return; 2299 const d = cell.dataset.date; 2300 // First click on a day: select it. Second click on the same already- 2301 // selected day: open the new-event form pre-filled with that date. 2302 if (_selectedDay === d) { 2303 _showEventForm(null, d); 2304 return; 2305 } 2306 _selectedDay = d; 2307 _render(); 2308 })); 2309 body.querySelectorAll('.cal-event-item').forEach(it => it.addEventListener('click', (e) => { 2310 if (e.target.closest('.cal-event-more')) return; 2311 const ev = _events.find(e => e.uid === it.dataset.uid); 2312 if (ev) _showEventForm(ev); 2313 })); 2314 _wireQuickDelete(body); 2315 2316 // Drag 2317 body.querySelectorAll('[draggable="true"][data-uid]').forEach(el => { 2318 el.addEventListener('dragstart', (e) => { 2319 _dragUid = el.dataset.uid; 2320 e.dataTransfer.effectAllowed = 'move'; 2321 el.classList.add('cal-dragging'); 2322 }); 2323 el.addEventListener('dragend', () => { 2324 el.classList.remove('cal-dragging'); 2325 _dragUid = null; 2326 body.querySelectorAll('.cal-drag-over').forEach(d => d.classList.remove('cal-drag-over')); 2327 }); 2328 }); 2329 // Helper â find the day cell directly under the cursor at (x,y). Reading 2330 // it from the cursor is more reliable than trusting whichever cell fired 2331 // the `drop` event: if the user releases over a nested event item or 2332 // multi-day bar, the drop fires on the inner element and the calling 2333 // cell's `data-date` may be the wrong row. 2334 const _cellAtPoint = (x, y) => { 2335 const stack = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [document.elementFromPoint(x, y)]; 2336 for (const el of stack) { 2337 if (!el || !el.closest) continue; 2338 // Prefer the month-view day cell, fall back to any data-date target 2339 // (e.g. week-view column) so week-view drag still works. 2340 const dayCell = el.closest('.cal-day[data-date]'); 2341 if (dayCell) return dayCell; 2342 const anyCell = el.closest('[data-date]'); 2343 if (anyCell) return anyCell; 2344 } 2345 return null; 2346 }; 2347 body.querySelectorAll('[data-date]').forEach(cell => { 2348 cell.addEventListener('dragover', (e) => { 2349 if (!_dragUid) return; 2350 e.preventDefault(); 2351 // Only highlight the cell genuinely under the cursor â prevents two 2352 // adjacent cells flashing as the cursor crosses a border. 2353 const target = _cellAtPoint(e.clientX, e.clientY); 2354 body.querySelectorAll('.cal-drag-over').forEach(c => { 2355 if (c !== target) c.classList.remove('cal-drag-over'); 2356 }); 2357 if (target) target.classList.add('cal-drag-over'); 2358 }); 2359 cell.addEventListener('dragleave', (e) => { 2360 // Only clear if the cursor really left this cell (dragleave fires when 2361 // entering a child too â that's the flicker bug). 2362 const target = _cellAtPoint(e.clientX, e.clientY); 2363 if (target !== cell) cell.classList.remove('cal-drag-over'); 2364 }); 2365 cell.addEventListener('drop', async (e) => { 2366 e.preventDefault(); 2367 e.stopPropagation(); 2368 body.querySelectorAll('.cal-drag-over').forEach(c => c.classList.remove('cal-drag-over')); 2369 if (!_dragUid) return; 2370 // Drop target = whichever cell is actually under the cursor at release, 2371 // not the bubbling target. Fixes "drops on wrong day" reports. 2372 const target = _cellAtPoint(e.clientX, e.clientY) || cell; 2373 const nd = target.dataset.date; 2374 const ev = _events.find(e => e.uid === _dragUid); 2375 if (!ev || !nd) return; 2376 const od = _localDateOf(ev.dtstart); 2377 if (od === nd) return; 2378 const diff = Math.round((new Date(nd + 'T00:00:00') - new Date(od + 'T00:00:00')) / 86400000); 2379 // Snapshot the original times for undo BEFORE we mutate. 2380 const undoSnap = { uid: ev.uid, dtstart: ev.dtstart, dtend: ev.dtend }; 2381 _pushCalUndo({ label: 'move', run: () => _updateEvent(undoSnap.uid, { dtstart: undoSnap.dtstart, dtend: undoSnap.dtend || undefined }).then(_render) }); 2382 await _updateEvent(ev.uid, { dtstart: _shiftDT(ev.dtstart, diff), dtend: ev.dtend ? _shiftDT(ev.dtend, diff) : undefined }); 2383 _render(); 2384 uiModule.showToast?.('Moved', { duration: 4000, action: 'Undo', actionHint: 'Ctrl+Z', onAction: _popAndRunCalUndo }); 2385 }); 2386 }); 2387} 2388 2389// ââ Undo stack (calendar) ââ 2390const _calUndoStack = []; 2391function _pushCalUndo(entry) { 2392 _calUndoStack.push(entry); 2393 if (_calUndoStack.length > 20) _calUndoStack.shift(); 2394} 2395function _popAndRunCalUndo() {
2396 const entry = _calUndoStack.pop(); 2397 if (entry && typeof entry.run === 'function') { 2398 try { entry.run(); } catch {} 2399 } 2400} 2401// Ctrl/Cmd+Z anywhere inside the calendar modal undoes the last drag-move. 2402if (typeof window !== 'undefined' && !window._calUndoBound) { 2403 window._calUndoBound = true; 2404 document.addEventListener('keydown', (e) => { 2405 if (!(e.ctrlKey || e.metaKey) || e.key !== 'z' || e.shiftKey) return; 2406 // Skip if the user's typing in a real field â let the browser's text undo run. 2407 const t = e.target; 2408 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; 2409 const modal = document.getElementById('calendar-modal'); 2410 if (!modal || modal.classList.contains('hidden') || !_calUndoStack.length) return; 2411 e.preventDefault(); 2412 _popAndRunCalUndo(); 2413 }); 2414} 2415 2416// ââ Calendar Settings ââ 2417 2418async function _showCalSettings() { 2419 const existing = document.getElementById('cal-settings-panel'); 2420 if (existing) { existing.remove(); return; } 2421 2422 const cals = _calendars; 2423 const COLORS = ['#5b8abf','#4caf50','#ff9800','#e91e63','#9c27b0','#00bcd4','#795548','#607d8b','#f44336','#7c4dff']; 2424 2425 const overlay = document.createElement('div'); 2426 overlay.id = 'cal-settings-panel'; 2427 overlay.className = 'modal'; 2428 overlay.style.display = 'flex'; 2429 overlay.style.zIndex = '999'; 2430 overlay.innerHTML = ` 2431 <div class="modal-content" style="width:420px;max-width:92vw;"> 2432 <div class="modal-header"> 2433 <h4>Calendar Settings</h4> 2434 <button class="close-btn" id="cal-settings-close">\u2716</button> 2435 </div> 2436 <div class="modal-body" style="padding:16px;display:flex;flex-direction:column;gap:16px;"> 2437 <div> 2438 <div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Your calendars</div> 2439 <div id="cal-settings-list" style="display:flex;flex-direction:column;gap:4px;"> 2440 ${cals.map(c => ` 2441 <div class="cal-settings-row" data-id="${_e(c.href)}" style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:color-mix(in srgb, var(--fg) 4%, transparent);"> 2442 <input type="color" value="${c.color || '#5b8abf'}" class="cal-s-color" style="width:24px;height:24px;border:none;background:none;cursor:pointer;padding:0;border-radius:50%;overflow:hidden;" /> 2443 <input type="text" value="${_e(c.name)}" class="cal-s-name" style="flex:1;background:none;border:1px solid var(--border);border-radius:4px;padding:3px 6px;color:var(--fg);font-size:12px;" /> 2444 <button class="cal-s-del" title="Delete calendar" style="background:none;border:none;color:var(--accent, var(--red));opacity:0.75;cursor:pointer;padding:2px;display:flex;position:relative;top:4px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button> 2445 </div> 2446 `).join('')} 2447 </div> 2448 <button class="memory-toolbar-btn" id="cal-settings-add" style="margin-top:8px;">
2449 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> 2450 New calendar 2451 </button> 2452 </div> 2453 <div style="border-top:1px solid var(--border);padding-top:12px;"> 2454 <div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Import calendar</div> 2455 <div style="display:flex;gap:8px;align-items:center;"> 2456 <label class="memory-toolbar-btn" style="cursor:pointer;"> 2457 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:relative;top:5px;margin-right:3px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> 2458 <span style="position:relative;top:4px;">Import .ics</span> 2459 <input type="file" accept=".ics,.ical" id="cal-import-file" style="display:none;" /> 2460 </label> 2461 <span id="cal-import-status" style="font-size:11px;opacity:0.6;"></span> 2462 </div> 2463 <div style="font-size:10px;opacity:0.4;margin-top:4px;">Upload a .ics file to import events. Google Calendar, Apple Calendar, and Outlook all export .ics files.</div> 2464 </div> 2465 <div style="border-top:1px solid var(--border);padding-top:12px;"> 2466 <div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Export calendar</div> 2467 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;"> 2468 ${cals.map(c => ` 2469 <button class="memory-toolbar-btn cal-s-export-chip" data-id="${_e(c.href)}" title="Download ${_e(c.name)}.ics" style="cursor:pointer;">
2470 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:relative;top:2px;margin-right:3px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 2471 <span style="position:relative;top:1px;">${_e(c.name)}</span> 2472 </button> 2473 `).join('')} 2474 </div> 2475 <div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div> 2476 </div> 2477 <div style="border-top:1px solid var(--border);padding-top:12px;"> 2478 <div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div> 2479 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> 2480 <button class="memory-toolbar-btn" id="cal-settings-sync-now" style="cursor:pointer;"> 2481 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:relative;top:2px;margin-right:3px;"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 2482 <span style="position:relative;top:1px;">Sync now</span> 2483 </button> 2484 <span id="cal-settings-sync-status" style="font-size:11px;opacity:0.6;"></span> 2485 </div> 2486 <div style="font-size:10px;opacity:0.4;margin-top:4px;">Pulls events from your CalDAV server. To connect or change CalDAV credentials, open <a href="#" id="cal-settings-open-caldav" style="color:var(--accent, var(--red));text-decoration:none;font-weight:600;">Settings â Integrations</a>.</div> 2487 </div> 2488 </div> 2489 </div> 2490 `; 2491 document.body.appendChild(overlay); 2492 2493 const cleanup = () => overlay.remove(); 2494 overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup); 2495 overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); }); 2496 2497 // Create a new (local) calendar. Defaults the name + next palette color, then 2498 // reopens the panel so the user can rename it inline and pick a color. 2499 overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => { 2500 const btn = e.currentTarget; 2501 btn.disabled = true; 2502 const color = COLORS[_calendars.length % COLORS.length]; 2503 try { 2504 const r = await fetch(`${API_BASE}/api/calendar/calendars?name=${encodeURIComponent('New calendar')}&color=${encodeURIComponent(color)}`, { method: 'POST', credentials: 'same-origin' }); 2505 const d = await r.json().catch(() => ({})); 2506 if (!r.ok || !d.ok) throw new Error(d.error || 'Failed to create calendar'); 2507 _calendars.push({ name: d.name, href: d.id, color: d.color }); 2508 _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); 2509 _render(); 2510 cleanup(); 2511 _showCalSettings(); 2512 // Focus the new row's name field so it's ready to rename. 2513 setTimeout(() => { 2514 const rows = document.querySelectorAll('#cal-settings-list .cal-settings-row'); 2515 const last = rows[rows.length - 1]; 2516 const nm = last?.querySelector('.cal-s-name'); 2517 if (nm) { nm.focus(); nm.select(); } 2518 }, 30); 2519 } catch (err) { 2520 btn.disabled = false; 2521 if (window.showError) window.showError(err.message || 'Failed to create calendar'); 2522 else console.error(err); 2523 } 2524 }); 2525 2526 // Color + name changes 2527 overlay.querySelectorAll('.cal-settings-row').forEach(row => { 2528 const id = row.dataset.id; 2529 const colorInput = row.querySelector('.cal-s-color'); 2530 const nameInput = row.querySelector('.cal-s-name'); 2531 const delBtn = row.querySelector('.cal-s-del'); 2532 2533 let saveTimer; 2534 const save = () => { 2535 clearTimeout(saveTimer); 2536 saveTimer = setTimeout(async () => { 2537 await fetch(`${API_BASE}/api/calendar/calendars/${id}?name=${encodeURIComponent(nameInput.value)}&color=${encodeURIComponent(colorInput.value)}`, { method: 'PUT' }); 2538 if (uiModule?.showToast) uiModule.showToast(`Saved â${nameInput.value || 'calendar'}â`); 2539 // Update local calendar list 2540 const c = _calendars.find(c => c.href === id); 2541 if (c) { c.name = nameInput.value; c.color = colorInput.value; } 2542 // Update colors on cached events 2543 for (const uid of Object.keys(_allEvents)) { 2544 if (_allEvents[uid].calendar_href === id) { 2545 _allEvents[uid].color = colorInput.value; 2546 _allEvents[uid].calendar = nameInput.value; 2547 } 2548 } 2549 localStorage.removeItem(LS_KEY); 2550 _fetchedRanges = []; 2551 _render(); 2552 }, 300); 2553 }; 2554 colorInput.addEventListener('input', save); 2555 nameInput.addEventListener('change', save); 2556 // Upgrade the native color box into the app's themed color picker. 2557 try { attachColorPicker(colorInput); } catch (_) {} 2558 2559 delBtn.addEventListener('click', async () => { 2560 const name = nameInput.value; 2561 if (!await window.styledConfirm(`Delete calendar "${name}" and all its events?`, { confirmText: 'Delete', danger: true })) return; 2562 await fetch(`${API_BASE}/api/calendar/calendars/${id}`, { method: 'DELETE' }); 2563 row.remove(); 2564 _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); 2565 _calendars = _calendars.filter(c => c.href !== id); 2566 _render(); 2567 }); 2568 }); 2569 2570 // ICS import 2571 overlay.querySelector('#cal-import-file').addEventListener('change', async (e) => { 2572 const file = e.target.files[0]; 2573 if (!file) return; 2574 const status = overlay.querySelector('#cal-import-status'); 2575 status.textContent = 'Importing...'; 2576 try { 2577 const fd = new FormData(); 2578 fd.append('file', file); 2579 const res = await fetch(`${API_BASE}/api/calendar/import`, { method: 'POST', body: fd, credentials: 'same-origin' }); 2580 // Try JSON first; fall back to text so HTML auth-walls and bare 2581 // 500s surface something the user can act on instead of the 2582 // generic "Import failed". 2583 let data = null, raw = '';
2584 try { data = await res.clone().json(); } catch (_) { raw = await res.text().catch(() => ''); } 2585 if (res.ok && data && data.ok) { 2586 status.textContent = `${data.imported} events imported to "${data.calendar}"` + (data.skipped ? ` (${data.skipped} skipped)` : ''); 2587 _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); 2588 await _fetchCalendars(); 2589 _render(); 2590 } else { 2591 // FastAPI HTTPException â {detail}; some routes use {error}. 2592 const reason = (data && (data.detail || data.error)) || raw.slice(0, 200) || `HTTP ${res.status}`; 2593 status.textContent = `Import failed: ${reason}`; 2594 console.error('Calendar import failed', res.status, data || raw); 2595 } 2596 } catch (err) { 2597 status.textContent = `Import failed: ${err.message || err}`; 2598 console.error('Calendar import threw', err); 2599 } 2600 e.target.value = ''; 2601 }); 2602 2603 // Export chips â one per calendar; downloads that calendar's .ics. 2604 overlay.querySelectorAll('.cal-s-export-chip').forEach(chip => { 2605 chip.addEventListener('click', () => { 2606 window.open(`${API_BASE}/api/calendar/export/${chip.dataset.id}`, '_blank'); 2607 }); 2608 }); 2609 2610 // Sync now â fires the CalDAV pull synchronously so we can show the 2611 // result inline, then refreshes the panel + calendar grid. 2612 overlay.querySelector('#cal-settings-sync-now')?.addEventListener('click', async (e) => { 2613 const btn = e.currentTarget; 2614 const status = overlay.querySelector('#cal-settings-sync-status'); 2615 btn.disabled = true; 2616 status.textContent = 'Syncingâ¦'; 2617 const data = await _syncCaldav(true) || {}; 2618 if (data.errors && data.errors.length) { 2619 status.textContent = `Sync failed: ${data.errors[0]}`; 2620 } else { 2621 const parts = []; 2622 if (data.events) parts.push(`${data.events} events`); 2623 if (data.deleted) parts.push(`${data.deleted} removed`); 2624 status.textContent = parts.length ? `Synced â ${parts.join(', ')}` : 'Synced â no changes'; 2625 _allEvents = {}; _fetchedRanges = [];
2626 try { localStorage.removeItem(LS_KEY); } catch (_) {} 2627 await _fetchCalendars(); 2628 _render(); 2629 // Reopen the panel so the calendars list reflects any new ones. 2630 const reopenWith = !!document.getElementById('cal-settings-panel'); 2631 cleanup(); 2632 if (reopenWith) _showCalSettings(); 2633 } 2634 btn.disabled = false; 2635 }); 2636 2637 // Integrations link â close this overlay and open Settings â Integrations. 2638 overlay.querySelector('#cal-settings-open-caldav')?.addEventListener('click', (e) => { 2639 e.preventDefault(); 2640 cleanup(); 2641 if (window.settingsModule && typeof window.settingsModule.open === 'function') { 2642 try { window.settingsModule.open('integrations'); return; } catch (_) {} 2643 } 2644 const modal = document.getElementById('settings-modal'); 2645 if (modal) { 2646 modal.classList.remove('hidden'); 2647 const tabBtn = modal.querySelector('[data-settings-tab="integrations"]'); 2648 if (tabBtn) tabBtn.click(); 2649 } 2650 }); 2651} 2652 2653// ââ Event Form ââ 2654 2655// Pull an explicit clock time out of a free-text title so it can overrule the 2656// time pickers on save (e.g. title "Standup 10am" wins over a 9pm picker). 2657// Returns {h, m} in 24h, or null when the title has no unambiguous time. 2658function _parseTitleTime(text) { 2659 if (!text) return null; 2660 // 12-hour with am/pm â "10am", "10:30 pm", "at 7 p.m." 2661 let m = text.match(/\b(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?\b/i); 2662 if (m) { 2663 let h = parseInt(m[1], 10); 2664 const mm = m[2] ? parseInt(m[2], 10) : 0; 2665 if (h < 1 || h > 12 || mm > 59) return null; 2666 const pm = m[3].toLowerCase() === 'p'; 2667 if (pm && h !== 12) h += 12; 2668 if (!pm && h === 12) h = 0; 2669 return { h, m: mm }; 2670 } 2671 // 24-hour HH:MM â "15:00", "at 9:30" (needs the colon to avoid matching 2672 // bare numbers like "room 5" or years). 2673 m = text.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); 2674 if (m) return { h: parseInt(m[1], 10), m: parseInt(m[2], 10) }; 2675 return null; 2676} 2677 2678function _showEventForm(existing, defaultDate, defaultEndDate) { 2679 const body = document.getElementById('cal-body'); 2680 if (!body) return; 2681 const isEdit = !!existing; 2682 const ds = existing ? _localDateOf(existing.dtstart) : (defaultDate || _today()); 2683 const de = existing && existing.dtend ? _localDateOf(existing.dtend) : (defaultEndDate || ds); 2684 const isMultiDay = ds !== de; 2685 const st = existing && !existing.all_day ? _fmtTime(existing.dtstart) : '09:00'; 2686 const et = existing && !existing.all_day && existing.dtend ? _fmtTime(existing.dtend) : '10:00'; 2687 // Default to all-day when dragging across multiple days 2688 const ad = existing ? existing.all_day : (defaultEndDate && defaultEndDate !== defaultDate); 2689 2690 let calOpts = _calendars.filter(c => !_hiddenCals.has(c.href)).map(c => 2691 `<option value="${_e(c.href)}" ${existing && existing.calendar_href === c.href ? 'selected' : ''}>${_e(c.name)}</option>` 2692 ).join(''); 2693 2694 // "Bespoke" event form: a big clock-face hero (time + date) and a single 2695 // title input. Everything else (location, description, recurrence, 2696 // reminder, color, calendar) is folded behind a click â focusing the 2697 // title or clicking "Add details" reveals it. Empty drafts feel like a 2698 // sticky-note; full-detail editing is one keystroke away. 2699 const _hasDetails = !!(existing && ( 2700 existing.location || existing.description || existing.rrule || 2701 (existing.color && existing.color.length) || 2702 isMultiDay 2703 )); 2704 const _expandedAtStart = isEdit && _hasDetails; 2705 2706 body.innerHTML = `<div class="cal-form cal-form-bespoke${_expandedAtStart ? ' is-expanded' : ''}"> 2707 <button type="button" class="cal-form-mobile-cancel" id="cal-form-mobile-cancel" title="Cancel" aria-label="Cancel event"> 2708 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 2709 </button> 2710 <div class="cal-form-today" id="cal-form-today">Today is <span id="cal-form-today-text">${_clockDate(_today())} · ${_nowClock()}</span></div> 2711 <div class="cal-hero"> 2712 <button type="button" class="cal-hero-time" id="cal-hero-time" title="Change time">
2713 <span class="cal-hero-clock" id="cal-hero-clock">${_clockFace(ad ? '' : st)}</span> 2714 <span class="cal-hero-ampm" id="cal-hero-ampm">${_clockAmpm(ad ? '' : st)}</span> 2715 </button> 2716 <button type="button" class="cal-hero-date" id="cal-hero-date" title="Change date">${_clockDate(ds)}</button> 2717 </div> 2718 2719 <div class="cal-title-wrap"> 2720 <input type="text" id="cal-f-sum" placeholder=" " value="${_e(existing?.summary || '')}" class="cal-input cal-hero-title" autocomplete="off" /> 2721 <span class="cal-title-hint" aria-hidden="true">${isEdit ? 'Event title' : 'Whatâs happening?'}<svg class="cal-title-enter-ico" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span> 2722 </div> 2723 2724 <div class="cal-form-details" id="cal-form-details" aria-hidden="${_expandedAtStart ? 'false' : 'true'}"> 2725 <div class="cal-form-row"> 2726 <input type="date" id="cal-f-date" value="${ds}" class="cal-input" /> 2727 <span style="opacity:0.3">to</span> 2728 <input type="date" id="cal-f-date-end" value="${de}" class="cal-input" /> 2729 <div class="cal-allday-ctrl"> 2730 <span class="cal-allday-label">All day</span> 2731 <label class="admin-switch cal-allday-switch"><input type="checkbox" id="cal-f-allday" ${ad ? 'checked' : ''} /><span class="admin-slider"></span></label> 2732 </div> 2733 </div> 2734 <div class="cal-form-row" id="cal-time-row" style="${ad ? 'dis
2734play:none' : ''}"> 2735 <input type="time" id="cal-f-start" value="${st}" class="cal-input cal-input-time" /> 2736 <span style="opacity:0.3">â</span> 2737 <input type="time" id="cal-f-end" value="${et}" class="cal-input cal-input-time" /> 2738 </div> 2739 <div class="cal-loc-row"> 2740 <input type="text" id="cal-f-loc" placeholder="Location" value="${_e(existing?.location || '')}" class="cal-input" /> 2741 <a id="cal-f-loc-map" class="cal-loc-map" href="#" target="_blank" rel="noopener noreferrer" title="Open in Maps" aria-label="Open in Apple Maps" tabindex="-1"> 2742 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 1 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> 2743 </a> 2744 </div> 2745 <select id="cal-f-rrule" class="cal-input"> 2746 <option value="" ${!existing?.rrule ? 'selected' : ''}>Does not repeat</option> 2747 <option value="FREQ=DAILY" ${existing?.rrule === 'FREQ=DAILY' ? 'selected' : ''}>Daily</option> 2748 <option value="FREQ=WEEKLY" ${existing?.rrule === 'FREQ=WEEKLY' ? 'selected' : ''}>Weekly</option> 2749 <option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" ${existing?.rrule === 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' ? 'selected' : ''}>Weekdays</option> 2750 <option value="FREQ=MONTHLY" ${existing?.rrule === 'FREQ=MONTHLY' ? 'selected' : ''}>Monthly</option> 2751 <option value="FREQ=YEARLY" ${existing?.rrule === 'FREQ=YEARLY' ? 'selected' : ''}>Yearly</option> 2752 </select> 2753 <textarea id="cal-f-desc" placeholder="Description" class="cal-input" rows="2">${_e(existing?.description || '')}</textarea> 2754 ${(() => { 2755 // Cookbook-task back-link. When the description carries a 2756 // "cookbook_task_id: <id>" marker (set by cookbookSchedule.js 2757 // when the user ticks "Create event in calendar"), render an 2758 // Open-task button so the user can jump straight to the 2759 // source task in the Tasks tab. 2760 const _ct = (existing?.description || '').match(/cookbook_task_id:\s*([A-Za-z0-9_-]+)/); 2761 if (!_ct) return ''; 2762 return `<div class="cal-form-row cal-form-cookbook-link" style="align-items:center;gap:8px;"> 2763 <button type="button" id="cal-f-open-task" data-task-id="${_e(_ct[1])}" 2764 style="display:inline-flex;align-items:center;gap:6px;background:transparent; 2765 color:var(--accent,var(--red));border:1px solid var(--border); 2766 border-radius:6px;padding:5px 10px;font:inherit;font-size:12px;cursor:pointer;"> 2767 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2768 <path d="M9 11l3 3L22 4"/> 2769 <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/> 2770 </svg> 2771 <span>Open in Tasks</span> 2772 </button> 2773 <span style="font-size:11px;opacity:0.5;">Linked to a Cookbook scheduled task</span> 2774 </div>`; 2775 })()} 2776 <div class="cal-form-row" style="align-items:center;gap:8px;"> 2777 <label style="font-size:11px;display:flex;align-items:center;gap:4px;">
2777<svg class="cal-remind-bell" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg><span style="opacity:0.5;">Reminder</span></label> 2778 <select id="cal-f-remind" class="cal-input" style="flex:1;"> 2779 <option value="" ${isEdit ? 'selected' : ''}>No reminder</option> 2780 <option value="0">At event time</option> 2781 <option value="5">5 minutes before</option> 2782 <option value="10">10 minutes before</option> 2783 <option value="15" ${!isEdit ? 'selected' : ''}>15 minutes before</option> 2784 <option value="30">30 minutes before</option> 2785 <option value="60">1 hour before</option> 2786 <option value="120">2 hours before</option> 2787 <option value="1440">1 day before</option> 2788 <option value="custom">Exact time...</option> 2789 </select> 2790 <input type="datetime-local" id="cal-f-remind-custom" class="cal-input" style="flex:1;display:none;" /> 2791 </div> 2792 <div class="cal-form-row" style="align-items:center;gap:8px;"> 2793 <label style="font-size:11px;opacity:0.5;">Color</label> 2794 <div class="note-color-picker" id="cal-f-colors"> 2795 ${CAL_COLORS.map(c => { 2796 const cur = existing?.color || ''; 2797 const isCustom = c.hex === 'custom'; 2798 const isActive = isCustom ? _isCalBgImage(cur) : (cur === c.hex || (!cur && !c.hex)); 2799 let bg; 2800 if (isCustom) { 2801 const url = _calBgImageUrl(cur); 2802 bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT; 2803 } else { 2804 bg = c.hex || 'var(--border)'; 2805 } 2806 return `<span class="note-color-dot${isActive ? ' active' : ''}" data-color="${c.hex}" style="background:${bg}" title="${c.name}"></span>`; 2807 }).join('')} 2808 </div> 2809 </div> 2810 ${_calendars.length > 1 ? `<select id="cal-f-cal" class="cal-input cal-f-cal-select">${calOpts}</select>` : ''} 2811 </div> 2812 2813 <div class="cal-form-actions"> 2814 ${isEdit ? `<button id="cal-f-del" class="cal-btn cal-btn-danger" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete</button>` : ''} 2815 <button id="cal-f-cancel" class="cal-btn" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button> 2816 <button id="cal-f-save" class="cal-btn cal-btn-primary" style="display:inline-flex;align-items:center;gap:5px;">${isEdit 2817 ? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>Save' 2818 : '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Create'}</button> 2819 </div> 2820 </div>`; 2821 2822 document.getElementById('cal-f-allday')?.addEventListener('change', (e) => { 2823 document.getElementById('cal-time-row').style.display = e.target.checked ? 'none' : ''; 2824 }); 2825 // Open-task back-link button â dynamically imports the tasks module 2826 // so the linkage works even if the user is opening the calendar 2827 // before they've touched the Tasks tab in this session. 2828 document.getElementById('cal-f-open-task')?.addEventListener('click', async (e) => { 2829 e.preventDefault(); 2830 const taskId = e.currentTarget?.dataset?.taskId || ''; 2831 try { 2832 const m = await import('/static/js/tasks.js'); 2833 const openTasks = m.openTasks || m.default?.openTasks; 2834 if (typeof openTasks === 'function') { openTasks(taskId); return; } 2835 } catch (_) {} 2836 document.getElementById('tool-tasks-btn')?.click(); 2837 }); 2838 // Keep end date >= start date 2839 document.getElementById('cal-f-date')?.addEventListener('change', () => { 2840 const s = document.getElementById('cal-f-date').value; 2841 const eEl = document.getElementById('cal-f-date-end'); 2842 if (eEl && eEl.value < s) eEl.value = s; 2843 }); 2844 // Color dot picker â also live-tints the form card (border, focus 2845 // rings, primary button) so the user sees the choice immediately. 2846 const _formCard = document.querySelector('.cal-form-bespoke'); 2847 // Dismiss the keyboard by pressing Enter in a single-line text field â the 2848 // âµ glyph next to the title hints at this. 2849 if (_formCard) { 2850 _formCard.querySelectorAll('input[type="text"]').forEach(inp => { 2851 inp.addEventListener('keydown', (e) => { 2852 if (e.key === 'Enter') { e.preventDefault(); inp.blur(); } 2853 }); 2854 }); 2855 } 2856 // Tint the calendar-picker select with the chosen calendar's colour so it's 2857 // clear which calendar the event lands in. 2858 const _calSel = document.getElementById('cal-f-cal'); 2859 if (_calSel) { 2860 const _tintCalSel = () => { 2861 const c = _calendars.find(x => x.href === _calSel.value); 2862 const col = (c && c.color && !_isCalBgImage(c.color)) ? c.color : 'var(--accent, var(--red))'; 2863 // Soft full-width background tint only â no side bar/border highlight. 2864 _calSel.style.background = `color-mix(in srgb, ${col} 16%, var(--bg))`; 2865 }; 2866 _calSel.addEventListener('change', _tintCalSel); 2867 _tintCalSel(); 2868 } 2869 const _applyFormTint = (hex) => { 2870 if (!_formCard) return; 2871 if (_isCalBgImage(hex)) { 2872 // Paint the form card with the uploaded image (mirrors how the notes 2873 // form previews a custom-bg note), plus a translucent overlay so text 2874 // stays readable. Chrome accent falls back to the theme accent. 2875 const url = _calBgImageUrl(hex); 2876 _formCard.style.setProperty('--ev-color', 'var(--accent)'); 2877 _formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`; 2878 _formCard.style.backgroundSize = 'cover'; 2879 _formCard.style.backgroundPosition = 'center'; 2880 _formCard.classList.add('cal-form-bg-image'); 2881 return; 2882 } 2883 // Clear any prior custom-bg styling. 2884 _formCard.classList.remove('cal-form-bg-image'); 2885 _formCard.style.backgroundImage = ''; 2886 _formCard.style.backgroundSize = ''; 2887 _formCard.style.backgroundPosition = ''; 2888 if (hex) _formCard.style.setProperty('--ev-color', hex); 2889 else _formCard.style.removeProperty('--ev-color'); 2890 }; 2891 document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(dot => { 2892 dot.addEventListener('click', async () => { 2893 // Custom dot: prompt for an image upload. Empty input â no-op. 2894 if (dot.dataset.color === 'custom') { 2895 const url = await _pickCalBgImage(); 2896 if (!url) return;
2897 const sentinel = 'bg:' + url; 2898 dot.dataset.color = sentinel; 2899 dot.style.background = `center/cover no-repeat url('${url}')`; 2900 document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); 2901 dot.classList.add('active'); 2902 _applyFormTint(sentinel); 2903 return; 2904 } 2905 document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); 2906 dot.classList.add('active'); 2907 _applyFormTint(dot.dataset.color || ''); 2908 }); 2909 }); 2910 // Initial tint for edit-an-existing-event so the card already reflects 2911 // the saved color when the form opens. 2912 _applyFormTint(existing?.color || ''); 2913 // When the user changes the start time, shift the end time by the same 2914 // delta so the event keeps its original duration (or a 1-hour default if 2915 // start == end). Skipped if the user has already nudged the end input 2916 // since opening the form â we don't want to clobber a deliberate edit. 2917 (function _wireStartShiftsEnd() { 2918 const startEl = document.getElementById('cal-f-start'); 2919 const endEl = document.getElementById('cal-f-end'); 2920 if (!startEl || !endEl) return; 2921 const _toMin = (v) => { 2922 if (!v || !/^\d{2}:\d{2}$/.test(v)) return null; 2923 const [h, m] = v.split(':').map(n => parseInt(n, 10)); 2924 return h * 60 + m; 2925 }; 2926 const _toHHMM = (mins) => { 2927 let m = ((mins % 1440) + 1440) % 1440; 2928 const hh = String(Math.floor(m / 60)).padStart(2, '0'); 2929 const mm = String(m % 60).padStart(2, '0'); 2930 return `${hh}:${mm}`; 2931 }; 2932 let prevStartMin = _toMin(startEl.value); 2933 endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; }); 2934 startEl.addEventListener('change', () => { 2935 const newStartMin = _toMin(startEl.value); 2936 const endMin = _toMin(endEl.value); 2937 if (newStartMin == null) { prevStartMin = newStartMin; return; } 2938 // Compute the duration before the change. Use the user's existing 2939 // startâend gap, fallback to 1 hour. 2940 let durationMin = 60; 2941 if (prevStartMin != null && endMin != null && endMin > prevStartMin) { 2942 durationMin = endMin - prevStartMin; 2943 } else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') { 2944 // User already set a custom end before changing start â leave it. 2945 prevStartMin = newStartMin; 2946 return; 2947 } 2948 endEl.value = _toHHMM(newStartMin + durationMin); 2949 prevStartMin = newStartMin; 2950 }); 2951 })(); 2952 // Custom reminder picker 2953 document.getElementById('cal-f-remind')?.addEventListener('change', (e) => { 2954 const customInput = document.getElementById('cal-f-remind-custom'); 2955 if (e.target.value === 'custom') { 2956 customInput.style.display = ''; 2957 // Default to 1 hour before event 2958 const dv = document.getElementById('cal-f-date')?.value || _today(); 2959 const st = document.getElementById('cal-f-start')?.value || '09:00'; 2960 const eventDt = new Date(`${dv}T${st}:00`); 2961 eventDt.setHours(eventDt.getHours() - 1); 2962 const pad = n => String(n).padStart(2, '0'); 2963 customInput.value = `${eventDt.getFullYear()}-${pad(eventDt.getMonth()+1)}-${pad(eventDt.getDate())}T${pad(eventDt.getHours())}:${pad(eventDt.getMinutes())}`; 2964 customInput.focus(); 2965 } else { 2966 customInput.style.display = 'none'; 2967 } 2968 // Jingle the bell whenever a non-empty reminder is picked. CSS handles the 2969 // animation; we just toggle the class so it re-fires on every change. 2970 const _bell = document.querySelector('.cal-remind-bell'); 2971 if (_bell && e.target.value) { 2972 _bell.classList.remove('jingling'); 2973 void _bell.offsetWidth; 2974 _bell.classList.add('jingling'); 2975 setTimeout(() => _bell.classList.remove('jingling'), 700); 2976 } 2977 }); 2978 const _cancelEventForm = () => _render(); 2979 document.getElementById('cal-f-cancel')?.addEventListener('click', _cancelEventForm); 2980 document.getElementById('cal-form-mobile-cancel')?.addEventListener('click', _cancelEventForm); 2981 document.getElementById('cal-f-save')?.addEventListener('click', async () => { 2982 const summary = document.getElementById('cal-f-sum').value.trim();
2983 if (!summary) { uiModule.showToast('Title required'); return; } 2984 const dv = document.getElementById('cal-f-date').value; 2985 const dvEnd = document.getElementById('cal-f-date-end').value || dv; 2986 const isAD = document.getElementById('cal-f-allday').checked; 2987 // Title overrules: if the title states a time, apply it to the start 2988 // (keeping the current duration) so the picker can't silently disagree. 2989 if (!isAD) { 2990 const tt = _parseTitleTime(summary); 2991 const startEl = document.getElementById('cal-f-start'); 2992 const endEl = document.getElementById('cal-f-end'); 2993 const newStart = tt ? `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}` : null; 2994 if (newStart && startEl && startEl.value !== newStart) { 2995 const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; }; 2996 const s0 = toMin(startEl.value), e0 = toMin(endEl?.value); 2997 const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60; 2998 startEl.value = newStart; 2999 const endMin = (tt.h * 60 + tt.m + dur) % 1440; 3000 if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}
3000:${String(endMin % 60).padStart(2, '0')}`; 3001 startEl.dispatchEvent(new Event('input')); 3002 } 3003 } 3004 const activeDot = document.querySelector('#cal-f-colors .note-color-dot.active'); 3005 const colorVal = activeDot?.dataset.color || ''; 3006 // Append the user's current UTC offset so the backend stores events as 3007 // proper UTC instants (is_utc=True). Without this, naive "10:00" gets 3008 // re-interpreted as local elsewhere â the timezone-misfire bug. 3009 const _tz = _tzOffset(); 3010 const payload = { 3011 summary, 3012 dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`, 3013 dtend: isAD ? dvEnd : `${dvEnd}T${document.getElementById('cal-f-end').value}:00${_tz}`, 3014 all_day: isAD, 3015 description: document.getElementById('cal-f-desc').value, 3016 location: document.getElementById('cal-f-loc').value, 3017 rrule: document.getElementById('cal-f-rrule').value || undefined, 3018 calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''), 3019 color: colorVal || undefined, 3020 }; 3021 try { 3022 if (isEdit) await _updateEvent(existing.uid, payload); 3023 else await _createEvent(payload); 3024 // Create reminder if selected 3025 const remindVal = document.getElementById('cal-f-remind')?.value; 3026 if (remindVal) { 3027 let remindAt; 3028 if (remindVal === 'custom') { 3029 const customVal = document.getElementById('cal-f-remind-custom')?.value; 3030 remindAt = customVal ? new Date(customVal) : null; 3031 } else { 3032 const eventStart = isAD ? new Date(dv + 'T00:00:00') : new Date(`${dv}T${document.getElementById('cal-f-start').value}:00`); 3033 remindAt = new Date(eventStart.getTime() - parseInt(remindVal) * 60 * 1000); 3034 } 3035 if (remindAt && remindAt > new Date()) { 3036 await _createEventReminder({ summary, dtstart: payload.dtstart, all_day: isAD, location: payload.location }, remindAt); 3037 } 3038 } 3039 _selectedDay = dv; _render(); 3040 } catch (e) { uiModule.showToast('Failed to save'); } 3041 }); 3042 document.getElementById('cal-f-del')?.addEventListener('click', async () => { 3043 const name = existing && existing.summary ? `"${existing.summary}"` : 'this event'; 3044 const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); 3045 if (!ok) return; 3046 try { await _deleteEvent(existing.uid); _render(); } 3047 catch (e) { uiModule.showToast('Failed to delete'); } 3048 }); 3049 // ââ Bespoke-form behavior ââââââââââââââââââââââââââââââââââââââââââ 3050 const formEl = body.querySelector('.cal-form'); 3051 const detailsEl = document.getElementById('cal-form-details'); 3052 const titleInput = document.getElementById('cal-f-sum'); 3053 3054 const setExpanded = (on) => { 3055 if (!formEl) return; 3056 formEl.classList.toggle('is-expanded', on); 3057 if (detailsEl) detailsEl.setAttribute('aria-hidden', on ? 'false' : 'true'); 3058 }; 3059 3060 // Focusing the title input unfolds the details once (new events). Edit 3061 // mode opens already expanded when there's any detail content to see. 3062 titleInput?.addEventListener('focus', () => setExpanded(true), { once: true }); 3063 3064 // Location â Apple Maps. The pin button next to the input is enabled 3065 // only when there's a non-empty location, and its href tracks the live 3066 // input value. Apple's universal URL opens the native Maps app on 3067 // iOS/macOS and falls back to a web view on everything else. 3068 const locInput = document.getElementById('cal-f-loc'); 3069 const locMap = document.getElementById('cal-f-loc-map'); 3070 const _syncLocMap = () => { 3071 if (!locMap) return; 3072 const v = (locInput?.value || '').trim(); 3073 if (!v) { 3074 locMap.classList.add('is-disabled'); 3075 locMap.removeAttribute('href'); 3076 locMap.setAttribute('tabindex', '-1'); 3077 locMap.setAttribute('aria-disabled', 'true'); 3078 } else { 3079 locMap.classList.remove('is-disabled'); 3080 locMap.setAttribute('href', 'https://maps.apple.com/?q=' + encodeURIComponent(v)); 3081 locMap.setAttribute('tabindex', '0'); 3082 locMap.removeAttribute('aria-disabled'); 3083 } 3084 }; 3085 locInput?.addEventListener('input', _syncLocMap); 3086 _syncLocMap(); 3087 3088 // Hero is clickable â clicking the time or date opens the matching 3089 // native picker. Expands the details panel first so the input has been 3090 // laid out (showPicker fails on display:none / 0-height inputs in some 3091 // browsers). 3092 const _openPicker = (inputId, { uncheckAllDay = false } = {}) => { 3093 setExpanded(true); 3094 const input = document.getElementById(inputId); 3095 if (!input) return; 3096 if (uncheckAllDay) { 3097 const allday = document.getElementById('cal-f-allday'); 3098 if (allday && allday.checked) { 3099 allday.checked = false;
3100 document.getElementById('cal-time-row').style.display = ''; 3101 _syncHero(); 3102 } 3103 } 3104 // Wait one frame for the reveal layout to settle. 3105 requestAnimationFrame(() => { 3106 input.focus(); 3107 try { if (typeof input.showPicker === 'function') input.showPicker(); } catch {} 3108 }); 3109 }; 3110 document.getElementById('cal-hero-time')?.addEventListener('click', (e) => { 3111 // Detect which segment of the visible clock was clicked (hh, mm, or 3112 // somewhere else) so clicking the minutes digits puts the caret right 3113 // on the minute field of the picker. 3114 const seg = e.target?.closest('[data-seg]')?.dataset?.seg; 3115 _openPicker('cal-f-start', { uncheckAllDay: true }); 3116 if (seg === 'mm') { 3117 // `<input type="time">` accepts setSelectionRange in Chromium for 3118 // selecting the minute segment; Firefox/Safari are no-ops but the 3119 // picker still opens, so nothing is lost. 3120 requestAnimationFrame(() => { 3121 const inp = document.getElementById('cal-f-start'); 3122 if (!inp) return; 3123 try { inp.setSelectionRange(3, 5); } catch {} 3124 }); 3125 } 3126 }); 3127 document.getElementById('cal-hero-date')?.addEventListener('click', () => { 3128 _openPicker('cal-f-date'); 3129 }); 3130 3131 // Live hero clock â keep the big time/date in sync with the inputs the 3132 // user can still tweak inside the details panel. 3133 const _syncHero = () => { 3134 const allday = document.getElementById('cal-f-allday')?.checked; 3135 const startVal = document.getElementById('cal-f-start')?.value || ''; 3136 const dateVal = document.getElementById('cal-f-date')?.value || ds; 3137 const clockEl = document.getElementById('cal-hero-clock'); 3138 const ampmEl = document.getElementById('cal-hero-ampm'); 3139 const dateEl = document.getElementById('cal-hero-date'); 3140 if (clockEl) clockEl.innerHTML = allday ? '<span class="cal-hero-clock-allday">All day</span>' : _clockFace(startVal); 3141 if (ampmEl) ampmEl.textContent = allday ? '' : _clockAmpm(startVal); 3142 if (dateEl) dateEl.textContent = _clockDate(dateVal); 3143 }; 3144 document.getElementById('cal-f-start')?.addEventListener('input', _syncHero); 3145 document.getElementById('cal-f-allday')?.addEventListener('change', _syncHero); 3146 document.getElementById('cal-f-date')?.addEventListener('change', _syncHero); 3147 _syncHero(); 3148 3149 // New events: expand the details up front (don't rely on the title's focus 3150 // event â programmatic .focus() is often a no-op on mobile, which would leave 3151 // the form showing only the title + buttons), then focus the title. 3152 if (!isEdit) { setExpanded(true); titleInput?.focus(); } 3153 3154 // Live "Today is â¦" tick. Updates every 30s; auto-stops the moment the 3155 // header element disappears (any _render() call swaps #cal-body's HTML). 3156 const _todayTextEl = document.getElementById('cal-form-today-text'); 3157 if (_todayTextEl) { 3158 const _tick = () => { 3159 const el = document.getElementById('cal-form-today-text'); 3160 if (!el) { clearInterval(_todayInterval); return; } 3161 el.textContent = `${_clockDate(_today())} · ${_nowClock()}`; 3162 }; 3163 const _todayInterval = setInterval(_tick, 30000); 3164 } 3165} 3166 3167// ââ Helpers ââ 3168 3169function _fmtDate(s) { return new Date(s + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } 3170 3171// Hero clock helpers â used by the bespoke event form. 3172// _clockFace returns the colon-separated digits ("HH : MM"), _clockAmpm 3173// returns "AM"/"PM"/"" (empty for all-day), _clockDate is a long form 3174// "Sat · May 10, 2026". 24-h time stays without an AM/PM marker. 3175function _clockFace(hhmm) { 3176 // Return the clock split into hh / separator / mm sub-spans so each 3177 // segment is individually clickable. The wrapping #cal-hero-clock has 3178 // its innerHTML re-set by _syncHero, so the spans round-trip cleanly. 3179 if (!hhmm) { 3180 return '<span class="cal-hero-clock-hh" data-seg="hh">â</span><span class="cal-hero-sep"> : </span><span class="cal-hero-clock-mm" data-seg="mm">â</span>'; 3181 } 3182 const [h, m] = hhmm.split(':'); 3183 const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); 3184 let hh = parseInt(h, 10); 3185 if (use12) { hh = ((hh + 11) % 12) + 1; }
3186 const hhStr = String(hh).padStart(2, '0'); 3187 return `<span class="cal-hero-clock-hh" data-seg="hh">${hhStr}</span><span class="cal-hero-sep"> : </span><span class="cal-hero-clock-mm" data-seg="mm">${m}</span>`; 3188} 3189function _clockAmpm(hhmm) { 3190 if (!hhmm) return ''; 3191 const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); 3192 if (!use12) return ''; 3193 const h = parseInt(hhmm.split(':')[0], 10); 3194 return h < 12 ? 'AM' : 'PM'; 3195} 3196function _clockDate(ds) { 3197 if (!ds) return ''; 3198 return new Date(ds + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); 3199} 3200function _nowClock() { 3201 // Live wall-clock string for the "Today is â¦" header. Locale-aware so 3202 // 24-h users don't see AM/PM. 3203 return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); 3204} 3205function _fmtTime(s) { 3206 if (!s || s.length < 16) return ''; 3207 // Tz-aware timestamps from CalDAV/import are stored as UTC instants and 3208 // serialized with Z/offset. Display them in the browser's local timezone; 3209 // legacy naive timestamps keep their written wall-clock time. 3210 if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(s)) { 3211 const d = new Date(s); 3212 if (!isNaN(d)) { 3213 return `${String(d.getHours()).padStart(2, '0')}
3213:${String(d.getMinutes()).padStart(2, '0')}`; 3214 } 3215 } 3216 return s.slice(11, 16); 3217} 3218function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } 3219 3220// Linkify a location string: URLs become clickable, plain addresses get a Maps link. 3221function _locHTML(loc) { 3222 if (!loc) return ''; 3223 const urlRe = /(https?:\/\/[^\s]+)/gi; 3224 if (urlRe.test(loc)) { 3225 return loc.replace(urlRe, (url) => { 3226 const safe = _e(url); 3227 return `<a href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`; 3228 }).replace(/\n/g, '<br>'); 3229 } 3230 // No URL â link the whole thing to OpenStreetMap. 3231 const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent(loc); 3232 return `<a href="${mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap">${_e(loc)}</a>`; 3233} 3234 3235// ââ Open / Close ââ 3236 3237let _wheelDebounce = 0; 3238function _wheelNav(e) { 3239 if (!_open) return; 3240 // Don't intercept scroll inside the day-detail panel or any other inner scroll area 3241 if (e.target.closest('.cal-day-detail') || e.target.closest('.cal-form')) return; 3242 const body = document.getElementById('cal-body'); 3243 if (!body) return; 3244 const now = Date.now(); 3245 if (now - _wheelDebounce < 300) { e.preventDefault(); return; } 3246 if (Math.abs(e.deltaY) < 30) return; 3247 _wheelDebounce = now; 3248 e.preventDefault(); 3249 if (e.deltaY > 0) { 3250 _slideDir = 1; 3251 if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() + 1, 0, 1); 3252 else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() + 7); 3253 else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + 1, 1); 3254 } else { 3255 _slideDir = -1; 3256 if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() - 1, 0, 1); 3257 else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() - 7); 3258 else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() - 1, 1); 3259 } 3260 _selectedDay = null; 3261 _render(); 3262} 3263 3264function openCalendar() { 3265 if (_open) return; 3266 // If currently minimized â restore in place, preserve all state 3267 if (Modals.isMinimized('calendar-modal')) { 3268 Modals.restore('calendar-modal'); 3269 _open = true; 3270 return; 3271 } 3272 _open = true; 3273 if (_todayCount() > 0) { _markBadgeSeen(); _updateBadge(); } 3274 _collapseSidebar(); 3275 const modal = _getModal(); 3276 // Clean up any leftover state from a previous swipe-dismiss 3277 modal.classList.remove('hidden', 'modal-minimized'); 3278 const _content = modal.querySelector('.modal-content'); 3279 if (_content) { 3280 _content.classList.remove('modal-closing', 'sheet-ready'); 3281 _content.style.transform = ''; 3282 _content.style.transition = ''; 3283 _content.style.animation = ''; 3284 _content.style.opacity = ''; 3285 } 3286 modal.style.display = 'flex'; 3287 Modals.register('calendar-modal', { 3288 railBtnId: 'rail-calendar', 3289 sidebarBtnId: 'tool-calendar-btn', 3290 closeFn: () => _doCloseCalendar(), 3291 restoreFn: () => {}, 3292 }); 3293 _currentDate = new Date(); 3294 _selectedDay = _today(); // auto-show today's events on open 3295 _view = 'month'; 3296 _scrollToTodayOnOpen = true; // first render lands on today's row 3297 _escHandler = (e) => { 3298 if (e.key === 'Escape') { 3299 // Layer Esc: close the topmost calendar surface first, only fall through 3300 // to closing the whole calendar when nothing else is on top. 3301 const settings = document.getElementById('cal-settings-panel'); 3302 if (settings) { settings.remove(); return; } 3303 if (document.querySelector('.cal-form')) { _render(); return; } 3304 closeCalendar(); 3305 } 3306 else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click(); 3307 else if (e.key === 'ArrowRight') document.getElementById('cal-next')?.click(); 3308 else if (e.key === 't' || e.key === 'T') document.getElementById('cal-today')?.click(); 3309 // Cmd/Ctrl+Z is handled by the module-level `_calUndoBound` listener, 3310 // which consumes the shared `_calUndoStack`. Don't duplicate here. 3311 }; 3312 document.addEventListener('keydown', _escHandler); 3313 const body = document.getElementById('cal-body'); 3314 if (body) { 3315 body.innerHTML = '<div class="cal-loading"></div>'; 3316 const wp = spinnerModule.createWhirlpool(28); 3317 wp.element.style.margin = '40px auto'; 3318 body.querySelector('.cal-loading').appendChild(wp.element); 3319 body.addEventListener('wheel', _wheelNav, { passive: false }); 3320 } 3321 _fetchCalendars().then(() => _render()); 3322} 3323 3324// Open the calendar focused on a specific event (by uid) or date. 3325// Used by the chat anchor-link delegate so `[Wake up](#event-<uid>)` 3326// opens the calendar on that day with the event highlighted. 3327async function openCalendarTo(target) {
3328 openCalendar(); 3329 if (!target) return; 3330 try { 3331 await _fetchCalendars(); 3332 // If target looks like an ISO date (YYYY-MM-DD...), go straight there. 3333 let dt = null; 3334 const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(String(target)); 3335 if (isoMatch) { 3336 dt = new Date(target); 3337 } else { 3338 // Treat as an event uid â find it among loaded events. 3339 const ev = (_events || []).find(e => e.uid === target || (e.uid || '').startsWith(target)); 3340 if (ev && ev.dtstart) dt = new Date(ev.dtstart); 3341 if (ev) _highlightEventUid = ev.uid; 3342 } 3343 if (dt && !isNaN(dt.getTime())) { 3344 _currentDate = new Date(dt); 3345 _selectedDay = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()); 3346 _view = 'month'; 3347 _render(); 3348 } 3349 } catch (e) { /* best-effort focus */ } 3350} 3351 3352let _highlightEventUid = null; 3353 3354function _doCloseCalendar() { 3355 _open = false; 3356 _restoreSidebar(); 3357 if (_modal) { 3358 _modal.style.display = 'none'; 3359 _modal.classList.add('hidden'); 3360 } 3361 if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; } 3362 // Drop any pending undo â closures captured event uids/state that may 3363 // no longer be valid by the time the user reopens. A reopened calendar 3364 // starts with a clean slate. 3365 _calUndoStack.length = 0; 3366} 3367 3368function closeCalendar() { 3369 if (!_open && !Modals.isMinimized('calendar-modal')) return; 3370 if (Modals.isRegistered('calendar-modal')) { 3371 Modals.close('calendar-modal'); 3372 } else { 3373 _doCloseCalendar(); 3374 } 3375} 3376 3377function isCalendarOpen() { 3378 // Treat minimized as "not open" so toggle handler will restore via Modals.toggle 3379 if (Modals.isMinimized('calendar-modal')) return false; 3380 return _open; 3381} 3382 3383// ââ Persistent cache (localStorage) ââ 3384const LS_KEY = 'odysseus-calendar-cache'; 3385const LS_TTL = 10 * 60 * 1000; // 10 min 3386 3387function _saveCache() { 3388 try { 3389 const data = { 3390 ts: Date.now(), 3391 calendars: _calendars, 3392 events: Object.values(_allEvents), 3393 ranges: _fetchedRanges, 3394 }; 3395 localStorage.setItem(LS_KEY, JSON.stringify(data)); 3396 } catch (e) {} 3397} 3398 3399function _loadCache() { 3400 try { 3401 const raw = localStorage.getItem(LS_KEY); 3402 if (!raw) return false; 3403 const data = JSON.parse(raw); 3404 if (!data.ts || Date.now() - data.ts > LS_TTL) return false; 3405 if (data.calendars) _calendars = data.calendars; 3406 if (data.events) data.events.forEach(ev => { _allEvents[ev.uid] = ev; }); 3407 // Don't restore _fetchedRanges â always re-fetch from API to pick up 3408 // external changes (e.g. TimeTree sync adding events) 3409 return true; 3410 } catch (e) { return false; } 3411} 3412 3413// Boot: load cache, refresh badge, prefetch current month 3414(async () => { 3415 _loadCache(); 3416 _updateBadge(); 3417 try { 3418 await _fetchCalendars(); 3419 _saveCache(); 3420 const [s, e] = _monthRange(new Date()); 3421 await _fetchEvents(s, e); 3422 _saveCache(); 3423 _updateBadge(); 3424 } catch (e) {} 3425})(); 3426 3427// Live-refresh when the AI agent adds/edits/deletes events. chat.js dispatches 3428// `calendar-refresh` after a manage_calendar tool call, so a new event shows up 3429// without the user hard-refreshing. Drop the cache (so adds/edits/deletes all 3430// reflect), refetch the visible range, re-render if open, and update the badge. 3431window.addEventListener('calendar-refresh', () => { 3432 _allEvents = {}; 3433 _fetchedRanges = []; 3434 const range = (_view === 'year') 3435 ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] 3436 : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate); 3437 _fetchEvents(range[0], range[1], /*force*/ true) 3438 .then(() => { if (_open) _render(); _updateBadge(); }) 3439 .catch(() => {}); 3440}); 3441 3442// Cross-session catch-up: when the tab/app becomes visible again (you alt-tab 3443// back, the mobile app comes to the foreground, or you switch back from 3444// another browser session), drop the range cache and re-fetch. Without this, 3445// a delete or add on desktop never propagates to the still-open mobile tab 3446// until the user does a full reload â so stale events sit there undeletable 3447// (they 404 on the server). Triggers on every visibility change but the 3448// fetch is cheap and already de-duped by _fetchPromise on line ~120. 3449let _lastVisRefetchAt = 0; 3450const _VIS_REFETCH_MIN_MS = 10 * 1000; // throttle if user is rapidly tab-flipping 3451document.addEventListener('visibilitychange', () => { 3452 if (document.visibilityState !== 'visible') return; 3453 const now = Date.now(); 3454 if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return; 3455 _lastVisRefetchAt = now; 3456 _fetchedRanges = []; 3457 const range = (_view === 'year') 3458 ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] 3459 : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate); 3460 _fetchEvents(range[0], range[1], /*force*/ true) 3461 .then(() => { if (_open) _render(); _updateBadge(); }) 3462 .catch(() => {}); 3463}); 3464
3465// Same idea for window-level focus â covers desktop alt-tabbing back to a 3466// browser that already had the tab visible (visibilitychange won't fire). 3467window.addEventListener('focus', () => { 3468 const now = Date.now(); 3469 if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return; 3470 _lastVisRefetchAt = now; 3471 _fetchedRanges = []; 3472 const range = (_view === 'year') 3473 ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] 3474 : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate); 3475 _fetchEvents(range[0], range[1], /*force*/ true) 3476 .then(() => { if (_open) _render(); _updateBadge(); }) 3477 .catch(() => {}); 3478}); 3479 3480// Calendar reminders are stored as Notes. The Notes reminder loop owns 3481// notification dispatch so calendar reminders do not fire twice. 3482 3483const calendarModule = { openCalendar, closeCalendar, isCalendarOpen }; 3484export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen }; 3485export default calendarModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.