PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/calendar.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:58 UTC 167,581 bytes, 3,485 lines download raw bytes

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">&larr;</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">&rarr;</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 &rsaquo; Integrations</a>' +
1597        ' &middot; ' +
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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
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.