2026-05-31 23:58:26 +09:00
/ * *
* Calendar Module — CalDAV - backed month / week / year calendar .
* /
import uiModule from './ui.js' ;
import spinnerModule from './spinner.js' ;
import * as Modals from './modalManager.js' ;
import { makeWindowDraggable } from './windowDrag.js' ;
import { attachColorPicker } from './colorPicker.js' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
import { bindMenuDismiss } from './escMenuStack.js' ;
2026-05-31 23:58:26 +09:00
import {
WEEKDAYS , MONTHS , MON _SHORT ,
CAL _PALETTE , CAL _COLORS , _CAL _CUSTOM _GRADIENT , _TYPE _PALETTE ,
_trashIcon , _moreIcon , _bellIcon ,
_isCalBgImage , _calBgImageUrl , _calBgCss ,
2026-06-02 09:14:52 -05:00
_calReadableTextColor ,
2026-05-31 23:58:26 +09:00
_ds , _addDays , _shiftDT , _tzOffset , _localDateOf ,
} from './calendar/utils.js' ;
const API _BASE = window . location . origin ;
// Open a file picker, upload the chosen image, return the URL string.
function _pickCalBgImage ( ) {
return new Promise ( resolve => {
const input = document . createElement ( 'input' ) ;
input . type = 'file' ;
input . accept = 'image/*' ;
input . style . cssText = 'position:fixed; left:-9999px; top:-9999px;' ;
document . body . appendChild ( input ) ;
let done = false ;
const finish = ( v ) => { if ( done ) return ; done = true ; input . remove ( ) ; resolve ( v ) ; } ;
input . addEventListener ( 'change' , async ( ) => {
const file = input . files ? . [ 0 ] ;
if ( ! file ) return finish ( null ) ;
const fd = new FormData ( ) ;
fd . append ( 'files' , file ) ;
try {
const res = await fetch ( ` ${ API _BASE } /api/upload ` , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
const fileId = data . files ? . [ 0 ] ? . id ;
if ( ! fileId ) throw new Error ( 'Upload failed' ) ;
finish ( ` ${ API _BASE } /api/upload/ ${ fileId } ` ) ;
} catch { finish ( null ) ; }
} ) ;
setTimeout ( ( ) => { if ( ! done && ! input . files ? . length ) finish ( null ) ; } , 30000 ) ;
input . click ( ) ;
} ) ;
}
let _open = false ;
// Set when the calendar opens so the first month render scrolls today's
// cell into view — the grid scrolls on mobile and today can sit below the
// fold, so we always land on the current date.
let _scrollToTodayOnOpen = false ;
let _currentDate = new Date ( ) ;
let _events = [ ] ;
let _allEvents = { } ;
let _fetchedRanges = [ ] ;
let _calendars = [ ] ;
let _hiddenCals = new Set ( ) ;
let _hiddenTypes = new Set ( ) ; // event_type values to hide
// "Only important" filter — when true, only events with importance
// high/critical render, regardless of their category. Toggled via the "!"
// chip; orthogonal to _hiddenTypes (which deals with event_type categories).
let _onlyImportant = false ;
let _filtersCollapsed = localStorage . getItem ( 'cal-filters-collapsed' ) === '1' ;
let _selectedDay = null ;
let _view = 'month' ;
let _searchQuery = '' ;
let _escHandler = null ;
let _modal = null ;
let _dragUid = null ;
let _sidebarWasOpen = false ;
let _slideDir = 0 ; // -1 = prev, +1 = next, 0 = none
// (Single undo stack lives at `_calUndoStack` further below; this used to
// hold a one-deep `_lastUndo` which has been collapsed into that stack.)
function _showCalUndoToast ( label , undoFn ) {
// Push onto the shared undo stack (also used by month drag-drop) so
// Cmd/Ctrl+Z and the toast button consume the same source of truth.
_pushCalUndo ( { label , run : undoFn } ) ;
const isMac = /Mac|iPhone|iPad/ . test ( navigator . platform || '' ) || /Mac/ . test ( navigator . userAgent || '' ) ;
uiModule . showToast ( label , {
action : 'Undo' ,
actionHint : isMac ? '⌘Z' : 'Ctrl+Z' ,
duration : 6000 ,
onAction : _popAndRunCalUndo ,
} ) ;
}
// ── API ──
function _rangeIsCached ( start , end ) {
// Check if [start, end] is fully covered by any single fetched range
for ( const [ s , e ] of _fetchedRanges ) {
if ( s <= start && e >= end ) return true ;
}
return false ;
}
function _filterPool ( start , end ) {
// Return all events in pool that overlap [start, end)
return Object . values ( _allEvents ) . filter ( ev => {
const evStart = ev . all _day ? ev . dtstart : _localDateOf ( ev . dtstart ) ;
const evEnd = ev . all _day ? ev . dtend : _localDateOf ( ev . dtend || ev . dtstart ) ;
return evStart < end && evEnd >= start ;
} ) . sort ( ( a , b ) => a . dtstart < b . dtstart ? - 1 : 1 ) ;
}
async function _fetchEvents ( start , end , force ) {
if ( ! force && _rangeIsCached ( start , end ) ) {
_events = _filterPool ( start , end ) ;
return ;
}
// Render from pool immediately if we have any cached data
const hasCache = Object . keys ( _allEvents ) . length > 0 ;
if ( hasCache ) _events = _filterPool ( start , end ) ;
const fetchPromise = fetch ( ` ${ API _BASE } /api/calendar/events?start= ${ start } &end= ${ end } ` , { credentials : 'same-origin' } )
2026-06-01 08:17:57 +02:00
. then ( r => {
if ( ! r . ok ) throw new Error ( 'HTTP ' + r . status ) ;
return r . json ( ) ;
} )
2026-05-31 23:58:26 +09:00
. then ( data => {
// On first fetch after cache load, replace pool entirely to avoid
// stale/duplicate UIDs from a previous backend (e.g. CalDAV → SQLite)
if ( hasCache && _fetchedRanges . length === 0 ) _allEvents = { } ;
( data . events || [ ] ) . forEach ( ev => { _allEvents [ ev . uid ] = ev ; } ) ;
_fetchedRanges . push ( [ start , end ] ) ;
_events = _filterPool ( start , end ) ;
if ( typeof _saveCache === 'function' ) _saveCache ( ) ;
// Re-render in background when new data arrives (if calendar still open)
if ( _open && hasCache ) _render ( ) ;
} )
. catch ( e => { console . error ( 'Calendar: failed to fetch events' , e ) ; } ) ;
// If we have cache, don't block on fetch — return immediately so render is instant
if ( hasCache ) return ;
// No cache — must await the fetch
await fetchPromise ;
}
// Prefetch surrounding months in background — fire-and-forget, no blocking
function _prefetchAdjacent ( ) {
const ranges = [ ] ;
if ( _view === 'month' || _view === 'week' ) {
// Prefetch ±2 months around current
for ( let offset = - 2 ; offset <= 2 ; offset ++ ) {
if ( offset === 0 ) continue ;
const d = new Date ( _currentDate . getFullYear ( ) , _currentDate . getMonth ( ) + offset , 1 ) ;
ranges . push ( _monthRange ( d ) ) ;
}
} else if ( _view === 'year' ) {
// Prefetch prev/next year
ranges . push ( [ ` ${ _currentDate . getFullYear ( ) - 1 } -01-01 ` , ` ${ _currentDate . getFullYear ( ) } -01-01 ` ] ) ;
ranges . push ( [ ` ${ _currentDate . getFullYear ( ) + 1 } -01-01 ` , ` ${ _currentDate . getFullYear ( ) + 2 } -01-01 ` ] ) ;
}
// Fire all prefetches in parallel, ignore failures
for ( const [ s , e ] of ranges ) {
if ( _rangeIsCached ( s , e ) ) continue ;
fetch ( ` ${ API _BASE } /api/calendar/events?start= ${ s } &end= ${ e } ` , { credentials : 'same-origin' } )
2026-06-01 08:17:57 +02:00
. then ( r => {
if ( ! r . ok ) throw new Error ( 'HTTP ' + r . status ) ;
return r . json ( ) ;
} )
2026-05-31 23:58:26 +09:00
. then ( d => {
( d . events || [ ] ) . forEach ( ev => { _allEvents [ ev . uid ] = ev ; } ) ;
_fetchedRanges . push ( [ s , e ] ) ;
} )
. catch ( ( ) => { } ) ;
}
}
let _calendarsError = null ;
// Guard so we only trigger an on-open CalDAV pull once per page load —
// every list/render path calls _fetchCalendars, but we only want to
// hit the remote server lazily on the first user open.
let _caldavSyncedOnce = false ;
async function _fetchCalendars ( ) {
_calendarsError = null ;
try {
const res = await fetch ( ` ${ API _BASE } /api/calendar/calendars ` , { credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
_calendars = data . calendars || [ ] ;
if ( data . error ) _calendarsError = data . error ;
_calendars . forEach ( ( c , i ) => {
if ( ! c . color || c . color . startsWith ( '<' ) ) c . color = CAL _PALETTE [ i % CAL _PALETTE . length ] ;
} ) ;
} catch ( e ) { _calendars = [ ] ; _calendarsError = e . message || 'Connection failed' ; }
// First open: fire a background CalDAV pull. We don't await — the
// initial render uses whatever's already cached locally, and the
// sync's writes show up on the next paint after it resolves.
if ( ! _caldavSyncedOnce ) {
_caldavSyncedOnce = true ;
_syncCaldav ( false ) ;
}
}
// Trigger a CalDAV pull. `interactive=true` waits for the result and
// refreshes the UI; false fires-and-forgets (used on first open). Both
// no-op silently if CalDAV isn't configured.
async function _syncCaldav ( interactive ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/calendar/sync ` , {
method : 'POST' , credentials : 'same-origin' ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( interactive ) return data ;
// Background path: if the pull actually changed anything, drop
// local caches and re-render so new events appear.
const changed = ( data . calendars || 0 ) > 0 && ( ( data . events || 0 ) > 0 || ( data . deleted || 0 ) > 0 ) ;
if ( changed ) {
_allEvents = { } ; _fetchedRanges = [ ] ;
try { localStorage . removeItem ( LS _KEY ) ; } catch ( _ ) { }
await _fetchCalendars ( ) ;
_render ( ) ;
}
} catch ( e ) {
if ( interactive ) return { errors : [ e . message || 'Sync failed' ] } ;
}
}
function _optimisticEvent ( data , uid ) {
const cal = _calendars . find ( c => c . href === data . calendar _href ) || _calendars [ 0 ] ;
return {
uid ,
summary : data . summary || '' ,
dtstart : data . dtstart ,
dtend : data . dtend || data . dtstart ,
all _day : ! ! data . all _day ,
description : data . description || '' ,
location : data . location || '' ,
rrule : data . rrule || '' ,
calendar : cal ? . name || '' ,
calendar _href : data . calendar _href || cal ? . href || '' ,
// Per-event color override (including the bg:<url> sentinel for custom
// backgrounds) wins over the parent calendar's default hex.
color : ( data . color !== undefined && data . color !== null ) ? data . color : ( cal ? . color || '' ) ,
} ;
}
// v2 review error-handling MEDs: every fetch here previously checked
// only `.then(r => r.json())` with no `r.ok` test. A 500/404 still
// resolved the promise and the optimistic state got promoted to truth.
// All three flows now inspect `r.ok` and roll back the optimistic
// state + surface a toast on the failure path.
async function _createEvent ( data ) {
const tempUid = 'temp-' + Date . now ( ) + '-' + Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) ;
_allEvents [ tempUid ] = _optimisticEvent ( data , tempUid ) ;
fetch ( ` ${ API _BASE } /api/calendar/events ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( data ) ,
} ) . then ( async r => {
if ( ! r . ok ) throw new Error ( 'HTTP ' + r . status ) ;
return r . json ( ) ;
} ) . then ( d => {
if ( d . uid ) {
delete _allEvents [ tempUid ] ;
_allEvents [ d . uid ] = _optimisticEvent ( data , d . uid ) ;
_saveCache && _saveCache ( ) ;
if ( _open ) _render ( ) ;
}
} ) . catch ( ( e ) => {
delete _allEvents [ tempUid ] ;
if ( _open ) _render ( ) ;
if ( window . uiModule ) window . uiModule . showError ( 'Failed to create event: ' + ( e ? . message || 'unknown' ) ) ;
} ) ;
return { uid : tempUid } ;
}
async function _updateEvent ( uid , data ) {
const merged = { ... ( _allEvents [ uid ] || { } ) , ... data } ;
const _preMergeBackup = _allEvents [ uid ] ;
_allEvents [ uid ] = _optimisticEvent ( merged , uid ) ;
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
// For recurring events the uid is a compound "{base_uid}::{date}" —
// the backend resolves it to the base series row. After the update,
// other occurrences of the same series are stale. Wipe the cache so
// a re-fetch picks up fresh data (next render + prefetch handles it).
const isRecurring = uid . includes ( '::' ) ;
2026-05-31 23:58:26 +09:00
fetch ( ` ${ API _BASE } /api/calendar/events/ ${ encodeURIComponent ( uid ) } ` , {
method : 'PUT' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( data ) ,
} ) . then ( r => {
if ( ! r . ok ) throw new Error ( 'HTTP ' + r . status ) ;
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
if ( isRecurring ) {
_fetchedRanges = [ ] ;
localStorage . removeItem ( LS _KEY ) ;
} else {
_saveCache && _saveCache ( ) ;
}
2026-05-31 23:58:26 +09:00
} ) . catch ( ( e ) => {
if ( _preMergeBackup ) _allEvents [ uid ] = _preMergeBackup ;
else delete _allEvents [ uid ] ;
if ( _open ) _render ( ) ;
if ( window . uiModule ) window . uiModule . showError ( 'Failed to update event: ' + ( e ? . message || 'unknown' ) ) ;
} ) ;
return { ok : true } ;
}
async function _deleteEvent ( uid ) {
const backup = _allEvents [ uid ] ;
delete _allEvents [ uid ] ;
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
const isRecurring = uid . includes ( '::' ) ;
2026-05-31 23:58:26 +09:00
fetch ( ` ${ API _BASE } /api/calendar/events/ ${ encodeURIComponent ( uid ) } ` , {
method : 'DELETE' , credentials : 'same-origin' ,
} ) . then ( r => {
if ( ! r . ok ) throw new Error ( 'HTTP ' + r . status ) ;
Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)
Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).
Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests
- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
distinguish occurrences from the same series. Non-recurring events
pass through with is_recurrence=false and series_uid=uid.
- Add _resolve_base_uid() to extract the base series UID from compound
UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
manage_calendar tool so edits/deletes always target the base row.
- Update manage_calendar tool to import and use _resolve_base_uid.
- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
invalidate localStorage cache after success so stale sibling
occurrences aren't shown.
- Add python-dateutil to requirements.txt as an explicit dependency.
- Add 14 regression tests in tests/test_calendar_recurrence.py
covering _resolve_base_uid edge cases, _expand_rrule with
yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
metadata inheritance.
- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.
* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings
Fix three edge cases in _expand_rrule:
1. Malformed-RRULE fallback now checks window overlap. list_events
fetches recurring rows with only dtstart < end_dt, so a broken
old recurring event could appear in unrelated future windows.
Now fallback returns [] unless the base event's dtstart/dtend
actually intersect [start, end).
2. Exclusive end boundary. rule.between(start, end, inc=True) was
inclusive on end, but the route contract and non-recurring SQL
filter both use [start, end). Added occ_start >= end guard.
3. Multi-day crossings. A recurring occurrence that starts before
the window but ends inside it was missed (only occ_start was
checked). Now expands from start - duration and filters by
occ_start < end AND occ_end > start, matching non-recurring
overlap behavior.
Tests: +4 tests for these cases (18 total)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:42:44 +02:00
if ( isRecurring ) {
_fetchedRanges = [ ] ;
localStorage . removeItem ( LS _KEY ) ;
} else {
_saveCache && _saveCache ( ) ;
}
2026-05-31 23:58:26 +09:00
} ) . catch ( ( e ) => {
if ( backup ) _allEvents [ uid ] = backup ;
if ( window . uiModule ) window . uiModule . showError ( 'Failed to delete event: ' + ( e ? . message || 'unknown' ) ) ;
if ( _open ) _render ( ) ;
} ) ;
return { ok : true } ;
}
// ── Date helpers ──
// _ds, _addDays, _shiftDT, _localDateOf, _tzOffset live in ./calendar/utils.js
// _monthRange / _weekRange / _today depend on _ds so they stay here.
function _today ( ) { return _ds ( new Date ( ) ) ; }
function _monthRange ( d ) {
const y = d . getFullYear ( ) , m = d . getMonth ( ) ;
const first = new Date ( y , m , 1 ) ;
const dow = ( first . getDay ( ) + 6 ) % 7 ;
const gs = new Date ( y , m , 1 - dow ) ;
const ge = new Date ( gs ) ; ge . setDate ( gs . getDate ( ) + 42 ) ;
return [ _ds ( gs ) , _ds ( ge ) ] ;
}
function _weekRange ( d ) {
const dow = ( d . getDay ( ) + 6 ) % 7 ;
const s = new Date ( d ) ; s . setDate ( d . getDate ( ) - dow ) ;
const e = new Date ( s ) ; e . setDate ( s . getDate ( ) + 7 ) ;
return [ _ds ( s ) , _ds ( e ) ] ;
}
function _eventsForDay ( dateStr ) {
return _events . filter ( e => {
if ( ! _eventVisible ( e ) ) return false ;
if ( e . all _day ) {
// Zero-duration all-day event (dtstart == dtend) is a single-day event
if ( e . dtstart === e . dtend ) return e . dtstart === dateStr ;
return e . dtstart <= dateStr && e . dtend > dateStr ;
}
// Multi-day timed events: show on each day they span
const startDate = _localDateOf ( e . dtstart ) ;
const endDate = _localDateOf ( e . dtend ) ;
if ( startDate !== endDate ) return startDate <= dateStr && endDate >= dateStr ;
return startDate === dateStr ;
} ) ;
}
function _calColor ( ev ) {
// Custom bg-image colors fall back to the parent calendar's solid hex
// in spots that need a plain color (dots, multi-day bars, week tile
// borders). The full image is shown via _calItemBgStyle() where it
// makes sense (event-item rows).
if ( _isCalBgImage ( ev . color ) ) {
const c = _calendars . find ( c => c . href === ev . calendar _href ) ;
return c ? . color || 'var(--accent)' ;
}
if ( ev . color && ! ev . color . startsWith ( '<' ) ) return ev . color ;
const c = _calendars . find ( c => c . href === ev . calendar _href ) ;
return c ? . color || 'var(--accent)' ;
}
2026-06-02 09:14:52 -05:00
function _calEventFg ( ev ) {
return _calReadableTextColor ( _calColor ( ev ) ) ;
}
2026-05-31 23:58:26 +09:00
// Extra inline style for an event row when the event has a custom BG image.
// Returns '' for normal solid-color events.
function _calItemBgStyle ( ev ) {
if ( ! _isCalBgImage ( ev . color ) ) return '' ;
const url = _calBgImageUrl ( ev . color ) . replace ( /'/g , "\\'" ) ;
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; ` ;
}
function _todayCount ( ) {
const t = _today ( ) ;
return _events . filter ( e => {
if ( ! _eventVisible ( e ) ) return false ;
if ( e . all _day ) {
if ( e . dtstart === e . dtend ) return e . dtstart === t ;
return e . dtstart <= t && e . dtend > t ;
}
return _localDateOf ( e . dtstart ) === t ;
} ) . length ;
}
// Per-event ⋮ menu: Remind me / Delete
function _wireQuickDelete ( body ) {
body . querySelectorAll ( '.cal-event-more' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const uid = btn . dataset . uid ;
if ( ! uid ) return ;
const ev = _allEvents [ uid ] ;
if ( ! ev ) return ;
_showEventMoreMenu ( ev , btn ) ;
} ) ;
} ) ;
}
function _clampDropdown ( dropdown , anchorRect ) {
const margin = 8 ;
const vw = window . innerWidth ;
const vh = window . innerHeight ;
const r = dropdown . getBoundingClientRect ( ) ;
const w = r . width , h = r . height ;
// Horizontal: prefer right-aligned with anchor, clamp to viewport
let left = anchorRect . right - w ;
if ( left + w > vw - margin ) left = vw - margin - w ;
if ( left < margin ) left = margin ;
// Vertical: below anchor if it fits, else above
let top = anchorRect . bottom + 4 ;
if ( top + h > vh - margin ) {
const above = anchorRect . top - 4 - h ;
top = above >= margin ? above : Math . max ( margin , vh - margin - h ) ;
}
dropdown . style . left = ` ${ left } px ` ;
dropdown . style . top = ` ${ top } px ` ;
dropdown . style . right = 'auto' ;
}
function _showEventMoreMenu ( ev , anchor ) {
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
document . querySelectorAll ( '.cal-event-dropdown' ) . forEach ( d => { if ( typeof d . _dismiss === 'function' ) d . _dismiss ( ) ; else d . remove ( ) ; } ) ;
2026-05-31 23:58:26 +09:00
const dropdown = document . createElement ( 'div' ) ;
dropdown . className = 'cal-event-dropdown' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
let closeMenu = ( ) => dropdown . remove ( ) ;
2026-05-31 23:58:26 +09:00
const rect = anchor . getBoundingClientRect ( ) ;
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; ` ;
const _item = ( icon , label , onClick , danger ) => {
const it = document . createElement ( 'div' ) ;
it . className = 'dropdown-item-compact' + ( danger ? ' dropdown-item-danger' : '' ) ;
it . innerHTML = ` <span class="dropdown-icon"> ${ icon } </span><span> ${ label } </span> ` ;
it . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; onClick ( ) ; } ) ;
return it ;
} ;
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>' ;
dropdown . appendChild ( _item ( _editIcon , 'Edit' , ( ) => {
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu ( ) ;
2026-05-31 23:58:26 +09:00
_showEventForm ( ev ) ;
} ) ) ;
dropdown . appendChild ( _item ( _trashIcon , 'Delete' , async ( ) => {
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu ( ) ;
2026-05-31 23:58:26 +09:00
const name = ev . summary ? ` " ${ ev . summary } " ` : 'this event' ;
const ok = await uiModule . styledConfirm ( ` Delete ${ name } ? ` , { confirmText : 'Delete' , danger : true } ) ;
if ( ! ok ) return ;
try { await _deleteEvent ( ev . uid ) ; setTimeout ( ( ) => _render ( ) , 100 ) ; } catch ( _ ) { }
} , true ) ) ;
document . body . appendChild ( dropdown ) ;
dropdown . _anchorRect = rect ;
_clampDropdown ( dropdown , rect ) ;
dropdown . style . visibility = '' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu = bindMenuDismiss ( dropdown , ( ) => dropdown . remove ( ) , ( ev2 ) => ! dropdown . contains ( ev2 . target ) && ev2 . target !== anchor ) ; }
2026-05-31 23:58:26 +09:00
async function _createEventReminder ( ev , dueDate ) {
// Store the reminder as an absolute UTC instant (with the Z suffix) so the
// notification poller fires at the right wall-clock moment regardless of:
// - the event's source timezone (CalDAV/import may carry a TZID),
// - the user's current local timezone differing from when the reminder
// was created,
// - any naive ISO mis-interpretation downstream.
// Both notes.js and the calendar poller already use `new Date(due_date)`,
// which handles Z-suffixed ISO correctly and converts back to local time
// when displayed.
const iso = new Date ( dueDate ) . toISOString ( ) ;
const startFmt = ev . all _day
? new Date ( ev . dtstart ) . toLocaleDateString ( [ ] , { weekday : 'short' , month : 'short' , day : 'numeric' } )
: new Date ( ev . dtstart ) . toLocaleString ( [ ] , { weekday : 'short' , month : 'short' , day : 'numeric' , hour : 'numeric' , minute : '2-digit' } ) ;
const summary = ev . summary || '(no title)' ;
const loc = ev . location ? ` @ ${ ev . location } ` : '' ;
const text = ` ${ summary } ${ loc } — ${ startFmt } ` ;
const payload = {
title : ` Reminder: ${ summary } ` ,
note _type : 'todo' ,
items : [ { text , done : false , checked : false } ] ,
label : 'calendar' ,
due _date : iso ,
source : 'calendar' ,
// Persist the EVENT'S absolute start so the notification body can be
// computed live at fire time ("Starts in 5 min") instead of using a
// stale string baked at scheduling time.
event _dtstart : new Date ( ev . dtstart ) . toISOString ( ) ,
} ;
try {
const res = await fetch ( ` /api/notes ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( payload ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'Failed' ) ;
const fmt = dueDate . toLocaleString ( [ ] , { month : 'short' , day : 'numeric' , hour : 'numeric' , minute : '2-digit' } ) ;
if ( uiModule . showToast ) uiModule . showToast ( ` Reminder set for ${ fmt } ` ) ;
try { window . notesModule ? . refreshDueBadge ? . ( { force : true } ) ; } catch { }
if ( 'Notification' in window && Notification . permission === 'default' ) {
try { Notification . requestPermission ( ) ; } catch { }
}
} catch ( e ) {
if ( uiModule . showError ) uiModule . showError ( 'Failed to create reminder' ) ;
}
}
// ── Sidebar collapse ──
function _collapseSidebar ( ) {
const sb = document . getElementById ( 'sidebar' ) ;
if ( sb && ! sb . classList . contains ( 'hidden' ) ) {
// Only remember the prior state on desktop. On mobile the sidebar is an
// overlay that the user intentionally swipes/taps away when the tool
// opens — popping it back on close is unwanted.
if ( window . innerWidth >= 700 ) _sidebarWasOpen = true ;
sb . classList . add ( 'hidden' ) ;
if ( window . syncRailSide ) window . syncRailSide ( ) ;
}
}
function _restoreSidebar ( ) {
if ( _sidebarWasOpen ) {
const sb = document . getElementById ( 'sidebar' ) ;
if ( sb ) { sb . classList . remove ( 'hidden' ) ; if ( window . syncRailSide ) window . syncRailSide ( ) ; }
_sidebarWasOpen = false ;
}
}
// ── Badge ──
const BADGE _SEEN _KEY = 'odysseus-calendar-badge-seen' ;
function _todayStr ( ) {
const d = new Date ( ) ;
return ` ${ d . getFullYear ( ) } - ${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } - ${ String ( d . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
}
function _isBadgeSeenToday ( ) {
try { return localStorage . getItem ( BADGE _SEEN _KEY ) === _todayStr ( ) ; } catch { return false ; }
}
function _markBadgeSeen ( ) {
try { localStorage . setItem ( BADGE _SEEN _KEY , _todayStr ( ) ) ; } catch { }
}
function _updateBadge ( ) {
const btn = document . getElementById ( 'tool-calendar-btn' ) ;
if ( ! btn ) return ;
let badge = btn . querySelector ( '.cal-badge' ) ;
const count = _todayCount ( ) ;
if ( count > 0 && ! _isBadgeSeenToday ( ) ) {
if ( ! badge ) { badge = document . createElement ( 'span' ) ; badge . className = 'cal-badge' ; btn . appendChild ( badge ) ; }
badge . title = ` ${ count } event ${ count > 1 ? 's' : '' } today ` ;
} else if ( badge ) badge . remove ( ) ;
}
// ── Modal ──
function _getModal ( ) {
if ( _modal ) return _modal ;
_modal = document . createElement ( 'div' ) ;
_modal . id = 'calendar-modal' ;
_modal . className = 'modal' ;
_modal . style . display = 'none' ;
_modal . innerHTML = `
< div class = "modal-content cal-modal-content" >
< div class = "modal-header" >
< 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" / > < / s v g > C a l e n d a r < / h 4 >
< button class = "close-btn" id = "cal-close" > ✖ < / b u t t o n >
< / d i v >
< div class = "modal-body" id = "cal-body" > < / d i v >
< / d i v > ` ;
document . body . appendChild ( _modal ) ;
_modal . querySelector ( '#cal-close' ) . addEventListener ( 'click' , closeCalendar ) ;
_modal . addEventListener ( 'click' , ( e ) => { if ( e . target === _modal ) closeCalendar ( ) ; } ) ;
// Make draggable — replaced ~50 lines of inline drag/dock plumbing with
// a single call to the shared helper. Calendar doesn't support fullscreen
// snap so no fsClass / enter/exit callbacks here.
{
const content = _modal . querySelector ( '.modal-content' ) ;
const header = _modal . querySelector ( '.modal-header' ) ;
if ( content && header ) {
makeWindowDraggable ( _modal , { content , header } ) ;
}
}
return _modal ;
}
// ── Render dispatch ──
// Stash the quick-add input's state (focus + caret + value) before a
// re-render so background fetches don't kick the user out mid-type. Picked
// up by _wireAll after the new DOM lands.
let _qaPendingRestore = null ;
function _saveQuickAddState ( ) {
const el = document . getElementById ( 'cal-quickadd' ) ;
if ( ! el || document . activeElement !== el ) { _qaPendingRestore = null ; return ; }
_qaPendingRestore = {
value : el . value ,
selStart : el . selectionStart ,
selEnd : el . selectionEnd ,
} ;
}
// True while the user is actively in the quick-add field. On mobile a
// programmatic re-focus after a DOM rebuild can't reopen the soft keyboard, so
// we must NOT swap the calendar body out from under an active quick-add — we
// defer the render and flush it on blur instead.
let _renderPending = false ;
let _qaSubmitting = false ;
function _qaTyping ( ) {
const el = document . getElementById ( 'cal-quickadd' ) ;
return ! ! el && document . activeElement === el ;
}
// Update only the search-results portion of the day-detail panel, keeping
// the search input element itself in the DOM so the on-screen keyboard
// doesn't dismiss between keystrokes. Used by the search input's `input`
// listener instead of a full _render().
function _updateDaySearchResults ( ) {
const dayDetail = document . querySelector ( '.cal-day-detail' ) ;
if ( ! dayDetail ) { _render ( ) ; return ; }
// Searching forces a selected day so the panel is always available
// (matches the logic in _render).
if ( _searchQuery && ! _selectedDay ) _selectedDay = _today ( ) ;
const ds = _selectedDay || _today ( ) ;
// Build the day-detail HTML in a detached node so we can extract its
// children (results, header, etc.) without touching the live input.
const tmp = document . createElement ( 'div' ) ;
tmp . innerHTML = _dayDetailHTML ( ds ) ;
const fresh = tmp . querySelector ( '.cal-day-detail' ) ;
if ( ! fresh ) return ;
// Remove every child of the live day-detail except the search-wrap.
const keep = dayDetail . querySelector ( '.cal-search-wrap' ) ;
[ ... dayDetail . children ] . forEach ( c => { if ( c !== keep ) c . remove ( ) ; } ) ;
// Move children from the fresh build into the live panel, skipping
// the duplicate search-wrap.
[ ... fresh . children ] . forEach ( c => {
if ( ! c . classList . contains ( 'cal-search-wrap' ) ) dayDetail . appendChild ( c ) ;
} ) ;
// Re-wire click handlers on the newly-inserted event rows.
dayDetail . querySelectorAll ( '.cal-event-item' ) . forEach ( it => {
it . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-event-more' ) ) return ;
const ev = _events . find ( x => x . uid === it . dataset . uid ) ;
if ( ev ) _showEventForm ( ev ) ;
} ) ;
} ) ;
dayDetail . querySelector ( '#cal-add-day' ) ? . addEventListener ( 'click' , ( ) => _showEventForm ( null , _selectedDay ) ) ;
_wireQuickDelete ( dayDetail ) ;
}
// Step between calendar views by "zoom level" — pinch IN goes year→month→week,
// pinch OUT goes the other way. Agenda is its own thing so it's excluded.
function _zoomView ( direction ) {
const chain = [ 'year' , 'month' , 'week' ] ;
const idx = chain . indexOf ( _view ) ;
if ( idx < 0 ) return ;
const next = idx + direction ;
if ( next < 0 || next >= chain . length ) return ;
_view = chain [ next ] ;
_render ( ) ;
}
// Monotonic counter bumped on every _render() call. The async per-view
// render functions snapshot this at entry and bail before painting DOM if
// a newer render has already started. Stops fast prev/next/today clicks
// from letting a slow fetch clobber the latest layout.
let _renderToken = 0 ;
function _isStaleRender ( t ) { return t !== _renderToken ; }
function _render ( ) {
// Don't rebuild the DOM while the user is typing in quick-add — defer it.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
// Empty state: no calendars configured or connection failed
if ( ! _calendars . length ) {
_renderEmpty ( ) ;
return ;
}
_renderToken ++ ;
// Search now lives inside the day-detail panel and filters in place,
// so we don't replace the whole calendar body when a query is active.
// Force a selected day in month/week so the panel (and its search box)
// is always available.
if ( _searchQuery && ( _view === 'month' || _view === 'week' ) && ! _selectedDay ) {
_selectedDay = _today ( ) ;
}
if ( _view === 'agenda' ) _renderAgenda ( ) ;
else if ( _view === 'year' ) _renderYear ( ) ;
else if ( _view === 'week' ) _renderWeek ( ) ;
else _renderMonth ( ) ;
// Prefetch adjacent in background after a short delay
setTimeout ( ( ) => _prefetchAdjacent ( ) , 200 ) ;
}
function _renderEmpty ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const hasError = ! ! _calendarsError ;
body . innerHTML = `
< div class = "cal-empty-state" >
< 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" >
< 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" / >
< / s v g >
< div class = "cal-empty-title" > $ { hasError ? 'Calendar unavailable' : 'No calendars yet' } < / d i v >
< div class = "cal-empty-msg" > $ { hasError ? _e ( _calendarsError ) : 'Create a local calendar, import an .ics file, or sync via CalDAV.' } < / d i v >
$ { hasError ? `
< button class = "cal-btn cal-btn-primary" id = "cal-goto-settings" > Open Settings < / b u t t o n >
` : `
< div style = "display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:4px;" >
< button class = "cal-btn cal-btn-primary" id = "cal-empty-new" > New calendar < / b u t t o n >
< button class = "cal-btn" id = "cal-empty-import" > Import . ics < / b u t t o n >
< / d i v >
< 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 > . < / d i v >
` }
< / d i v > ` ;
document . getElementById ( 'cal-goto-settings' ) ? . addEventListener ( 'click' , ( ) => {
closeCalendar ( ) ;
const modal = document . getElementById ( 'settings-modal' ) ;
if ( modal ) {
modal . classList . remove ( 'hidden' ) ;
const tab = modal . querySelector ( '[data-settings-tab="integrations"]' ) ;
if ( tab ) tab . click ( ) ;
}
} ) ;
// New / Import open the calendar settings panel; the panel already
// has the "New calendar" button and the .ics file picker. Import
// triggers the file picker immediately so it's a one-click flow.
document . getElementById ( 'cal-empty-new' ) ? . addEventListener ( 'click' , ( ) => {
_showCalSettings ( ) ;
setTimeout ( ( ) => document . getElementById ( 'cal-settings-add' ) ? . click ( ) , 50 ) ;
} ) ;
document . getElementById ( 'cal-empty-import' ) ? . addEventListener ( 'click' , ( ) => {
_showCalSettings ( ) ;
setTimeout ( ( ) => document . getElementById ( 'cal-import-file' ) ? . click ( ) , 50 ) ;
} ) ;
document . getElementById ( 'cal-empty-caldav' ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
closeCalendar ( ) ;
// Integrations is an admin tab — settingsModule.open() only sets
// the .active class for admin tabs; the actual panel renders via
// adminModule.open(). Without the admin-first branch the modal
// appears with Integrations highlighted but showing the previous
// panel, so the user has to click the tab again to land there.
if ( window . adminModule && typeof window . adminModule . open === 'function' ) {
try { window . adminModule . open ( 'integrations' ) ; return ; } catch ( _ ) { }
}
if ( window . settingsModule && typeof window . settingsModule . open === 'function' ) {
try { window . settingsModule . open ( 'integrations' ) ; return ; } catch ( _ ) { }
}
const modal = document . getElementById ( 'settings-modal' ) ;
if ( modal ) {
modal . classList . remove ( 'hidden' ) ;
const tab = modal . querySelector ( '[data-settings-tab="integrations"]' ) ;
if ( tab ) tab . click ( ) ;
}
} ) ;
}
// ── Header + Filters (shared) ──
function _isoWeekNumber ( d ) {
// ISO 8601: weeks start Monday; week 1 contains the year's first Thursday.
const tgt = new Date ( d . getFullYear ( ) , d . getMonth ( ) , d . getDate ( ) ) ;
// Move to Thursday of this week (so the year is determined correctly).
tgt . setDate ( tgt . getDate ( ) + 3 - ( ( tgt . getDay ( ) + 6 ) % 7 ) ) ;
const yearStart = new Date ( tgt . getFullYear ( ) , 0 , 1 ) ;
return Math . ceil ( ( ( tgt - yearStart ) / 86400000 + 1 ) / 7 ) ;
}
function _headerHTML ( ) {
const weekSuffix = _view === 'week'
? ` <span class="cal-week-no">W ${ _isoWeekNumber ( _currentDate ) } </span> `
: '' ;
return ` <div class="cal-toolbar">
< div class = "cal-toolbar-nav" >
< button class = "cal-nav" id = "cal-prev" > & larr ; < / b u t t o n >
< button class = "cal-nav cal-today-btn" id = "cal-today" > Today < / b u t t o n >
< span class = "cal-title" > $ { _view === 'agenda' ? 'Upcoming' : MONTHS [ _currentDate . getMonth ( ) ] + ' ' + _currentDate . getFullYear ( ) } $ { weekSuffix } < / s p a n >
< button class = "cal-nav" id = "cal-next" > & rarr ; < / b u t t o n >
< / d i v >
< div class = "cal-toolbar-right" >
< div class = "cal-view-toggle" >
$ { [ 'week' , 'month' , 'year' , 'agenda' ] . map ( v =>
` <button class="cal-view-btn ${ _view === v ? ' active' : '' } " data-view=" ${ v } "> ${ v [ 0 ] . toUpperCase ( ) + v . slice ( 1 ) } </button> `
) . join ( '' ) }
< / d i v >
< 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" / > < / s v g > < / b u t t o n >
< 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>' } < / b u t t o n >
$ { _filtersToggleHTML ( ) }
< button class = "cal-add-btn cal-add-btn-text" id = "cal-add" title = "New event" > < span class = "cal-add-plus" > + < / s p a n > < s p a n c l a s s = " c a l - a d d - l a b e l " > N e w < / s p a n > < / b u t t o n >
< / d i v >
< / d i v >
< div class = "cal-quickadd-row" id = "cal-quickadd-row" >
< input
type = "text"
id = "cal-quickadd"
class = "cal-quickadd-input"
placeholder = " "
autocomplete = "off"
/ >
< 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" / > < / s v g > < / s p a n >
< span class = "cal-quickadd-status" id = "cal-quickadd-status" > < / s p a n >
< / d i v > ` ;
}
function _filtersData ( ) {
// Build chip HTML once; reused by toolbar toggle + chip-row renderers.
let calFilters = '' ;
if ( _calendars . length > 1 ) {
calFilters = _calendars . map ( c => {
const off = _hiddenCals . has ( c . href ) ;
return ` <label class="cal-filter-item ${ off ? ' cal-filter-off' : '' } " data-href=" ${ _e ( c . href ) } ">
< span class = "cal-filter-dot" style = "background:${c.color}" > < / s p a n > $ { _ e ( c . n a m e ) } < / l a b e l > ` ;
} ) . join ( '' ) ;
}
const presentTypes = new Set ( _events . map ( e => e . event _type ) . filter ( Boolean ) ) ;
const hasUntagged = _events . some ( e => ! e . event _type ) ;
const hasImportant = _events . some ( e => e . importance === 'high' || e . importance === 'critical' ) ;
if ( hasImportant ) presentTypes . add ( '!' ) ;
const typeOrder = [ '!' , 'work' , 'personal' , 'health' , 'travel' , 'meal' , 'social' , 'admin' , 'other' ] ;
let typeFilters = '' ;
for ( const t of typeOrder ) {
if ( ! presentTypes . has ( t ) ) continue ;
const off = ( t === '!' ) ? false : _hiddenTypes . has ( t ) ;
const active = ( t === '!' ) && _onlyImportant ;
const label = t === '!' ? '! important' : t ;
typeFilters += ` <label class="cal-filter-item ${ off ? ' cal-filter-off' : '' } ${ active ? ' cal-filter-active' : '' } ${ t === '!' ? ' cal-filter-important' : '' } " data-type=" ${ t } ">
< span class = "cal-filter-dot" style = "background:${_TYPE_PALETTE[t]}" > < / s p a n > $ { l a b e l } < / l a b e l > ` ;
}
if ( hasUntagged ) {
const off = _hiddenTypes . has ( '__untagged__' ) ;
typeFilters += ` <label class="cal-filter-item ${ off ? ' cal-filter-off' : '' } " data-type="__untagged__">
< span class = "cal-filter-dot" style = "background:${_TYPE_PALETTE.untagged}" > < / s p a n > u n t a g g e d < / l a b e l > ` ;
}
return { calFilters , typeFilters } ;
}
function _filtersToggleHTML ( ) {
// Inline toolbar button only. The chip row renders separately below.
const { calFilters , typeFilters } = _filtersData ( ) ;
if ( ! calFilters && ! typeFilters ) return '' ;
return ` <button class="cal-filter-toggle" id="cal-filter-toggle" title=" ${ _filtersCollapsed ? 'Show filters' : 'Hide filters' } "> ${ _filtersCollapsed ? '+ tags' : '− tags' } </button> ` ;
}
function _filtersRowHTML ( ) {
// Chip row beneath the toolbar — empty when collapsed.
if ( _filtersCollapsed ) return '' ;
const { calFilters , typeFilters } = _filtersData ( ) ;
if ( ! calFilters && ! typeFilters ) return '' ;
const sep = ( calFilters && typeFilters ) ? '<span style="opacity:0.3;margin:0 4px">·</span>' : '' ;
return ` <div class="cal-filters"> ${ calFilters } ${ sep } ${ typeFilters } </div> ` ;
}
function _eventVisible ( e ) {
if ( _hiddenCals . has ( e . calendar _href ) ) return false ;
// "Only important" mode short-circuits category filters: nothing else
// matters except whether the event itself is high/critical.
if ( _onlyImportant ) {
return e . importance === 'high' || e . importance === 'critical' ;
}
if ( e . event _type ) {
if ( _hiddenTypes . has ( e . event _type ) ) return false ;
} else if ( _hiddenTypes . has ( '__untagged__' ) ) {
return false ;
}
return true ;
}
// ── Month View ──
async function _renderMonth ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const _tk = _renderToken ;
const [ rs , re ] = _monthRange ( _currentDate ) ;
await _fetchEvents ( rs , re ) ;
if ( _isStaleRender ( _tk ) ) return ; // newer render already in flight
const today = _today ( ) ;
const y = _currentDate . getFullYear ( ) , m = _currentDate . getMonth ( ) ;
const slideClass = _slideDir > 0 ? ' cal-slide-in-right' : _slideDir < 0 ? ' cal-slide-in-left' : '' ;
_slideDir = 0 ;
let h = _headerHTML ( ) + _filtersRowHTML ( ) + ` <div class="cal-grid ${ slideClass } "> ` ;
h += '<div class="cal-week-headers">' ;
for ( const wd of WEEKDAYS ) h += ` <div class="cal-weekday"> ${ wd } </div> ` ;
h += '</div>' ;
const first = new Date ( y , m , 1 ) ;
const dow = ( first . getDay ( ) + 6 ) % 7 ;
const gs = new Date ( y , m , 1 - dow ) ;
const multiDay = _events . filter ( e => {
if ( ! _eventVisible ( e ) ) return false ;
const startD = new Date ( e . dtstart ) , endD = new Date ( e . dtend ) ;
return Math . round ( ( endD - startD ) / 86400000 ) > 1 || ( ! e . all _day && _localDateOf ( e . dtstart ) !== _localDateOf ( e . dtend ) ) ;
} ) ;
const multiUids = new Set ( multiDay . map ( e => e . uid ) ) ;
// Render 6 week rows. Each row is a positioned container that holds
// 7 day cells AND any multi-day bars that span the row, drawn as an
// absolute overlay on top of the cells. This avoids the old "each
// bar lives inside its start cell and gets clipped at the cell edge"
// problem so a multi-day event reads as one continuous line across
// every day it covers.
for ( let row = 0 ; row < 6 ; row ++ ) {
// Count how many multi-day bars overlap any column in this row so
// cells can reserve top padding for them — otherwise the bars
// (drawn as absolute overlays) sit on top of the day-number and
// single-event rows below.
const rowStartCd0 = new Date ( gs ) ; rowStartCd0 . setDate ( gs . getDate ( ) + row * 7 ) ;
const rowEndCd0 = new Date ( gs ) ; rowEndCd0 . setDate ( gs . getDate ( ) + row * 7 + 6 ) ;
const rowStart0 = _ds ( rowStartCd0 ) ;
const rowEnd0 = _ds ( rowEndCd0 ) ;
const barsInRow = multiDay . filter ( md => {
const mdStart = _localDateOf ( md . dtstart ) ;
const mdEnd = _localDateOf ( md . dtend ) ;
return ! ( mdEnd < rowStart0 || mdStart > rowEnd0 ) ;
} ) . length ;
h += ` <div class="cal-week-row" style="--bars: ${ barsInRow } "> ` ;
// Day cells for this row
for ( let col = 0 ; col < 7 ; col ++ ) {
const i = row * 7 + col ;
const cd = new Date ( gs ) ; cd . setDate ( gs . getDate ( ) + i ) ;
const d = _ds ( cd ) ;
const isOther = cd . getMonth ( ) !== m ;
const cls = 'cal-day' + ( isOther ? ' cal-other' : '' ) + ( d === today ? ' cal-today' : '' ) + ( d === _selectedDay ? ' cal-selected' : '' ) ;
h += ` <div class=" ${ cls } " data-date=" ${ d } "><span class="cal-day-num"> ${ cd . getDate ( ) } </span> ` ;
// Single events — show up to 3 inline rows (multi-day events are
// drawn separately as an overlay below).
const singles = _eventsForDay ( d ) . filter ( e => ! multiUids . has ( e . uid ) ) ;
if ( singles . length ) {
const maxInline = window . innerWidth <= 768 ? 2 : 3 ;
const showInline = singles . slice ( 0 , maxInline ) ;
for ( const ev of showInline ) {
const t = ev . all _day ? '' : _fmtTime ( ev . dtstart ) ;
const _impMark = ev . importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
: ev . importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '' ;
const _typeBadge = ev . event _type ? ` <span class="cal-event-type-badge" data-type=" ${ _e ( ev . event _type ) } " title=" ${ _e ( ev . event _type ) } "></span> ` : '' ;
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 : '' } ">
< span class = "cal-event-row-dot" style = "background:${_calColor(ev)}" > < / s p a n >
$ { _typeBadge }
$ { t ? ` <span class="cal-event-row-time"> ${ t } </span> ` : '' }
< span class = "cal-event-row-name" > $ { _impMark } $ { _e ( ev . summary ) } < / s p a n >
< / d i v > ` ;
}
if ( singles . length > maxInline ) h += ` <div class="cal-event-more">+ ${ singles . length - maxInline } more</div> ` ;
}
h += '</div>' ;
}
// Multi-day overlay bars for this row. Stack each bar one slot below
// the previous so two events on the same row don't overlap.
let barSlot = 0 ;
for ( const md of multiDay ) {
const mdStart = _localDateOf ( md . dtstart ) ;
const mdEnd = _localDateOf ( md . dtend ) ;
// Compute the row's date range
const rowStartCd = new Date ( gs ) ; rowStartCd . setDate ( gs . getDate ( ) + row * 7 ) ;
const rowEndCd = new Date ( gs ) ; rowEndCd . setDate ( gs . getDate ( ) + row * 7 + 6 ) ;
const rowStart = _ds ( rowStartCd ) ;
const rowEnd = _ds ( rowEndCd ) ;
if ( mdEnd < rowStart || mdStart > rowEnd ) continue ; // not in this row
// Column within the row where the bar starts and how many days it spans
const startCol = mdStart < rowStart ? 0 : ( ( new Date ( mdStart + 'T00:00:00' ) - rowStartCd ) / 86400000 ) ;
const endCol = mdEnd > rowEnd ? 6 : ( ( new Date ( mdEnd + 'T00:00:00' ) - rowStartCd ) / 86400000 ) ;
const startColInt = Math . round ( startCol ) ;
const endColInt = Math . round ( endCol ) ;
const span = endColInt - startColInt + 1 ;
2026-06-02 09:14:52 -05:00
h += ` <div class="cal-multiday" style="--col: ${ startColInt } ;--span: ${ span } ;--slot: ${ barSlot } ;background: ${ _calColor ( md ) } ;--cal-event-fg: ${ _calEventFg ( md ) } " draggable="true" data-uid=" ${ _e ( md . uid ) } " title=" ${ _e ( md . summary ) } "> ${ _e ( md . summary ) } </div> ` ;
2026-05-31 23:58:26 +09:00
barSlot ++ ;
}
h += '</div>' ;
}
h += '</div>' ;
if ( _selectedDay ) h += _dayDetailHTML ( _selectedDay ) ;
// Capture the grid's scroll position before innerHTML wipes it —
// selecting a day shouldn't jump the user back to the top of the
// month, that hides the row they just clicked.
const _prevGrid = body . querySelector ( '.cal-grid' ) ;
const _prevScroll = _prevGrid ? _prevGrid . scrollTop : 0 ;
// If the user grabbed the quick-add field mid-fetch, skip the swap (which
// would destroy the focused input + drop the keyboard) and defer until blur.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
body . innerHTML = h ;
const _newGrid = body . querySelector ( '.cal-grid' ) ;
if ( _newGrid && _prevScroll ) _newGrid . scrollTop = _prevScroll ;
// On open, scroll today's cell into view so the current date is always
// visible even when its row sits below the fold (mobile scrolls the grid).
if ( _scrollToTodayOnOpen ) {
_scrollToTodayOnOpen = false ;
const todayCell = body . querySelector ( '.cal-day.cal-today' ) ;
if ( todayCell && _newGrid ) {
requestAnimationFrame ( ( ) => {
try { todayCell . scrollIntoView ( { block : 'center' , behavior : 'auto' } ) ; }
catch { _newGrid . scrollTop = Math . max ( 0 , todayCell . offsetTop - _newGrid . clientHeight / 2 ) ; }
} ) ;
}
}
_wireAll ( body ) ;
_updateBadge ( ) ;
}
// ── Week View ──
// Hour-grid week view. Each column is a day; a vertical hour rail on the
// left labels 6am– 11pm. Events render as absolute-positioned blocks.
// Drag on an empty cell to scaffold a new event for that range.
// Render the full 24-hour day so events at any hour are reachable.
// On first open the grid auto-scrolls to ~7 AM so the default landing
// still matches the old "morning is visible" behaviour; subsequent
// renders preserve whatever scrollTop the user is on.
const WEEK _HOUR _START = 0 ;
const WEEK _HOUR _END = 24 ;
const WK _DEFAULT _SCROLL _HOUR = 7 ;
let _wkScrollY = null ; // remembered scroll position across renders
let _wkScrolledOnce = false ; // tracks the first auto-scroll-to-morning
// pixel height per hour — user-zoomable, persisted in localStorage so the
// preference sticks across reloads. Bounds keep the layout sane.
const WK _PX _MIN = 28 ;
const WK _PX _MAX = 120 ;
const WK _PX _DEFAULT = 64 ;
let WEEK _HOUR _PX = ( ( ) => {
const saved = parseInt ( localStorage . getItem ( 'cal-wk-hour-px' ) || '' , 10 ) ;
return ( saved >= WK _PX _MIN && saved <= WK _PX _MAX ) ? saved : WK _PX _DEFAULT ;
} ) ( ) ;
function _wkSetZoom ( px ) {
// Capture the hour currently at the top of the viewport so the same
// hour stays put across the zoom-induced re-render — otherwise the
// saved pixel scrollTop misaligns at the new px/hour.
const wrap = document . querySelector ( '.cal-wk-wrap' ) ;
let _hourAtTop = null ;
if ( wrap && WEEK _HOUR _PX ) _hourAtTop = wrap . scrollTop / WEEK _HOUR _PX ;
WEEK _HOUR _PX = Math . max ( WK _PX _MIN , Math . min ( WK _PX _MAX , Math . round ( px ) ) ) ;
try { localStorage . setItem ( 'cal-wk-hour-px' , String ( WEEK _HOUR _PX ) ) ; } catch { }
if ( _hourAtTop != null ) _wkScrollY = Math . round ( _hourAtTop * WEEK _HOUR _PX ) ;
if ( _view === 'week' ) _render ( ) ;
}
function _wkZoomBy ( delta ) { _wkSetZoom ( WEEK _HOUR _PX + delta ) ; }
function _wkHours ( ) { return WEEK _HOUR _END - WEEK _HOUR _START ; }
// Round a Y offset (px from top of grid) to the nearest 15-minute slot,
// returns minutes-from-WEEK_HOUR_START.
function _wkPxToMin ( y ) {
const totalMin = ( y / WEEK _HOUR _PX ) * 60 ;
return Math . max ( 0 , Math . round ( totalMin / 15 ) * 15 ) ;
}
function _wkMinToHHMM ( mins ) {
const t = WEEK _HOUR _START * 60 + mins ;
const h = Math . floor ( t / 60 ) , m = t % 60 ;
return ` ${ String ( h ) . padStart ( 2 , '0' ) } : ${ String ( m ) . padStart ( 2 , '0' ) } ` ;
}
function _wkFormatHourLabel ( h ) {
const use12 = ( new Date ( ) ) . toLocaleString ( ) . toLowerCase ( ) . match ( /am|pm/ ) ;
if ( ! use12 ) return ` ${ String ( h ) . padStart ( 2 , '0' ) } :00 ` ;
const ampm = h < 12 ? 'AM' : 'PM' ;
const hh = ( ( h + 11 ) % 12 ) + 1 ;
return ` ${ hh } ${ ampm } ` ;
}
function _wkEventTopHeight ( ev , dayStr ) {
// Convert event start/end (local) into top/height in px relative to the
// day's grid origin. Clamp to visible window.
// The dtstart/dtend strings are like "2026-05-11T09:00:00" (no tz), so
// pull the time portion directly to avoid TZ math drift; falls back to
// Date math if the string isn't shaped as expected.
const _toMin = ( iso , fallbackDate ) => {
if ( ! iso ) return null ;
const m = iso . match ( /T(\d{2}):(\d{2})/ ) ;
if ( m ) {
// If the event spans into a previous/next day, clamp to today's bounds.
const evDate = iso . slice ( 0 , 10 ) ;
if ( evDate < fallbackDate ) return 0 ; // event started before today
if ( evDate > fallbackDate ) return 24 * 60 ; // event ends after today
return parseInt ( m [ 1 ] , 10 ) * 60 + parseInt ( m [ 2 ] , 10 ) ;
}
// All-day or date-only — treat as start of day.
return 0 ;
} ;
const startMin = _toMin ( ev . dtstart , dayStr ) ;
const endMin = _toMin ( ev . dtend , dayStr ) ? ? ( startMin + 60 ) ;
const gridStart = WEEK _HOUR _START * 60 ;
const gridEnd = WEEK _HOUR _END * 60 ;
const sMin = Math . max ( gridStart , startMin ) ;
const eMin = Math . min ( gridEnd , Math . max ( endMin , sMin + 15 ) ) ;
const top = ( sMin - gridStart ) * ( WEEK _HOUR _PX / 60 ) ;
const height = Math . max ( 18 , ( eMin - sMin ) * ( WEEK _HOUR _PX / 60 ) ) ;
return { top , height } ;
}
async function _renderWeek ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const _tk = _renderToken ;
// Stash current scroll so we can restore after re-render (zoom, drag,
// etc. all rebuild the body).
const _prevWrap = body . querySelector ( '.cal-wk-wrap' ) ;
if ( _prevWrap ) _wkScrollY = _prevWrap . scrollTop ;
const [ rs , re ] = _weekRange ( _currentDate ) ;
await _fetchEvents ( rs , re ) ;
if ( _isStaleRender ( _tk ) ) return ;
const today = _today ( ) ;
const ws = new Date ( rs + 'T00:00:00' ) ;
// Build day list once (used for both all-day strip and grid).
const days = [ ] ;
for ( let i = 0 ; i < 7 ; i ++ ) {
const d = new Date ( ws ) ; d . setDate ( ws . getDate ( ) + i ) ;
days . push ( { d , ds : _ds ( d ) , idx : i } ) ;
}
// Hour rail on the left. The spacer up top hosts the zoom controls
// (toolbar is already crowded — this empty 56-px corner is a free home).
let railHtml = ` <div class="cal-wk-rail">
< div class = "cal-wk-rail-spacer" >
< button class = "cal-wk-zoom" id = "cal-wk-zoom-out" title = "Zoom out (– )" aria - label = "Zoom out" > − < / b u t t o n >
< button class = "cal-wk-zoom" id = "cal-wk-zoom-in" title = "Zoom in (+)" aria - label = "Zoom in" > + < / b u t t o n >
< / d i v > ` ;
for ( let h = WEEK _HOUR _START ; h < WEEK _HOUR _END ; h ++ ) {
railHtml += ` <div class="cal-wk-rail-cell" style="height: ${ WEEK _HOUR _PX } px;"><span> ${ _wkFormatHourLabel ( h ) } </span></div> ` ;
}
railHtml += '</div>' ;
// Day columns
let colsHtml = '<div class="cal-wk-cols">' ;
for ( const { d , ds , idx } of days ) {
const isToday = ds === today ;
const allDayEvents = _eventsForDay ( ds ) . filter ( e => _eventVisible ( e ) && e . all _day ) ;
const timedEvents = _eventsForDay ( ds ) . filter ( e => _eventVisible ( e ) && ! e . all _day ) ;
const isSun = d . getDay ( ) === 0 ;
colsHtml += ` <div class="cal-wk-col ${ isToday ? ' cal-wk-today' : '' } ${ isSun ? ' cal-wk-sun' : '' } " data-date=" ${ ds } "> ` ;
colsHtml += ` <div class="cal-wk-col-head"><span class="cal-wk-dn"> ${ WEEKDAYS [ idx ] } </span><span class="cal-wk-dt"> ${ d . getDate ( ) } </span></div> ` ;
// All-day strip
colsHtml += ` <div class="cal-wk-allday"> ` ;
for ( const ev of allDayEvents ) {
2026-06-02 09:14:52 -05:00
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> ` ;
2026-05-31 23:58:26 +09:00
}
colsHtml += ` </div> ` ;
// Hour-grid body
colsHtml += ` <div class="cal-wk-grid" data-date=" ${ ds } " style="height: ${ _wkHours ( ) * WEEK _HOUR _PX } px;"> ` ;
// Hour cell lines
for ( let h = WEEK _HOUR _START ; h < WEEK _HOUR _END ; h ++ ) {
colsHtml += ` <div class="cal-wk-cell" data-hour=" ${ h } " style="height: ${ WEEK _HOUR _PX } px;"></div> ` ;
}
// Now-line indicator (only on today)
if ( isToday ) {
const now = new Date ( ) ;
const minSinceStart = ( now . getHours ( ) - WEEK _HOUR _START ) * 60 + now . getMinutes ( ) ;
if ( minSinceStart >= 0 && minSinceStart <= _wkHours ( ) * 60 ) {
const top = minSinceStart * ( WEEK _HOUR _PX / 60 ) ;
colsHtml += ` <div class="cal-wk-now" style="top: ${ top } px;"></div> ` ;
}
}
// Timed event blocks. Each block carries a 6-px bottom-edge handle
// for drag-to-resize (extend duration without opening the form).
for ( const ev of timedEvents ) {
const { top , height } = _wkEventTopHeight ( ev , ds ) ;
const t = _fmtTime ( ev . dtstart ) + '– ' + _fmtTime ( ev . dtend ) ;
// Custom-bg events get the image as the tile background; solid-color
// events keep the original tinted treatment.
let bgDecl ;
if ( _isCalBgImage ( ev . color ) ) {
const _url = _calBgImageUrl ( ev . color ) . replace ( /'/g , "\\'" ) ;
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; ` ;
} else {
bgDecl = ` background:color-mix(in srgb, ${ _calColor ( ev ) } 18%, var(--bg)); ` ;
}
colsHtml += ` <div class="cal-wk-block" data-uid=" ${ _e ( ev . uid ) } " style="top: ${ top } px;height: ${ height } px;border-left-color: ${ _calColor ( ev ) } ; ${ bgDecl } "> ` ;
colsHtml += ` <div class="cal-wk-block-name"> ${ _e ( ev . summary ) } </div> ` ;
colsHtml += ` <div class="cal-wk-block-time"> ${ t } </div> ` ;
colsHtml += ` <div class="cal-wk-block-resize" title="Drag to resize"></div> ` ;
colsHtml += ` </div> ` ;
}
colsHtml += ` </div></div> ` ; // /cal-wk-grid /cal-wk-col
}
colsHtml += '</div>' ;
let h = _headerHTML ( ) + _filtersRowHTML ( ) ;
h += ` <div class="cal-wk-wrap"> ${ railHtml } ${ colsHtml } </div> ` ;
if ( _selectedDay ) h += _dayDetailHTML ( _selectedDay ) ;
// If the user grabbed the quick-add field mid-fetch, skip the swap (which
// would destroy the focused input + drop the keyboard) and defer until blur.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
body . innerHTML = h ;
_wireAll ( body ) ;
// Single click (tap) an event block → open edit form. A drag-to-move or
// drag-to-resize sets `justResized` in its mouseup so the trailing click
// doesn't also open the form; the bottom-edge resize handle is ignored too.
body . querySelectorAll ( '.cal-wk-block, .cal-wk-allday-event' ) . forEach ( el => {
el . addEventListener ( 'click' , ( e ) => {
if ( e . target . classList . contains ( 'cal-wk-block-resize' ) ) return ;
if ( el . dataset . justResized ) { delete el . dataset . justResized ; return ; }
e . stopPropagation ( ) ;
const ev = _events . find ( x => x . uid === el . dataset . uid ) ;
if ( ev ) _showEventForm ( ev ) ;
} ) ;
} ) ;
// Drag the body of a block to reschedule (different day or time). The
// bottom-edge handle has its own gesture (resize) and stops here, so
// the two never fight. Same duration is preserved.
body . querySelectorAll ( '.cal-wk-block' ) . forEach ( block => {
block . addEventListener ( 'mousedown' , ( e ) => {
if ( e . button !== 0 ) return ;
if ( e . target . classList . contains ( 'cal-wk-block-resize' ) ) return ; // resize wins
e . preventDefault ( ) ;
const uid = block . dataset . uid ;
const ev = _events . find ( x => x . uid === uid ) ;
if ( ! ev ) return ;
const cols = Array . from ( body . querySelectorAll ( '.cal-wk-grid' ) ) ;
if ( ! cols . length ) return ;
// Original timing
const m1 = ( ev . dtstart || '' ) . match ( /T(\d{2}):(\d{2})/ ) ;
const m2 = ( ev . dtend || '' ) . match ( /T(\d{2}):(\d{2})/ ) ;
const startMin0 = m1 ? parseInt ( m1 [ 1 ] , 10 ) * 60 + parseInt ( m1 [ 2 ] , 10 ) : 0 ;
const endMin0 = m2 ? parseInt ( m2 [ 1 ] , 10 ) * 60 + parseInt ( m2 [ 2 ] , 10 ) : startMin0 + 60 ;
const durationMin = Math . max ( 15 , endMin0 - startMin0 ) ;
// Where did the cursor grab the block? (offset from block-top in px)
const blockRect = block . getBoundingClientRect ( ) ;
const grabOffsetPx = e . clientY - blockRect . top ;
// Ghost that follows the cursor across columns.
const ghost = block . cloneNode ( true ) ;
ghost . classList . add ( 'cal-wk-block-ghost' ) ;
ghost . style . pointerEvents = 'none' ;
ghost . style . opacity = '0.85' ;
ghost . querySelector ( '.cal-wk-block-resize' ) ? . remove ( ) ;
// Mute the original while dragging.
block . style . opacity = '0.25' ;
let nextDs = null ;
let nextStartMin = startMin0 ;
let activeGrid = null ;
let moved = false ;
const _attachGhost = ( grid ) => {
if ( activeGrid === grid ) return ;
activeGrid = grid ;
grid . appendChild ( ghost ) ;
} ;
const onMove = ( mv ) => {
moved = true ;
// Pick the column under the cursor. If the cursor lands between
// columns (gutter/border) or just outside the grid horizontally,
// snap to the nearest column instead of giving up — that's why
// horizontal cross-day drag could feel stuck before.
let cur = cols . find ( c => {
const r = c . getBoundingClientRect ( ) ;
return mv . clientX >= r . left && mv . clientX <= r . right ;
} ) ;
if ( ! cur ) {
let best = null , bestDist = Infinity ;
for ( const c of cols ) {
const r = c . getBoundingClientRect ( ) ;
const cx = ( r . left + r . right ) / 2 ;
const d = Math . abs ( mv . clientX - cx ) ;
if ( d < bestDist ) { bestDist = d ; best = c ; }
}
cur = best ;
}
if ( ! cur ) return ;
_attachGhost ( cur ) ;
const r = cur . getBoundingClientRect ( ) ;
const yIn = Math . max ( 0 , Math . min ( cur . clientHeight , mv . clientY - r . top ) ) ;
// Subtract the grab offset so the cursor stays at the same spot
// inside the block as you drag it around.
const blockTopY = yIn - grabOffsetPx ;
const snapMin = Math . max ( 0 , Math . round ( _wkPxToMin ( blockTopY ) / 15 ) * 15 ) ;
nextStartMin = WEEK _HOUR _START * 60 + snapMin ;
nextDs = cur . dataset . date ;
const top = ( nextStartMin - WEEK _HOUR _START * 60 ) * ( WEEK _HOUR _PX / 60 ) ;
const height = durationMin * ( WEEK _HOUR _PX / 60 ) ;
ghost . style . top = top + 'px' ;
ghost . style . height = height + 'px' ;
const hh = String ( Math . floor ( nextStartMin / 60 ) ) . padStart ( 2 , '0' ) ;
const mm = String ( nextStartMin % 60 ) . padStart ( 2 , '0' ) ;
const hh2 = String ( Math . floor ( ( nextStartMin + durationMin ) / 60 ) ) . padStart ( 2 , '0' ) ;
const mm2 = String ( ( nextStartMin + durationMin ) % 60 ) . padStart ( 2 , '0' ) ;
const timeEl = ghost . querySelector ( '.cal-wk-block-time' ) ;
if ( timeEl ) timeEl . textContent = ` ${ hh } : ${ mm } – ${ hh2 } : ${ mm2 } ` ;
} ;
const onUp = async ( up ) => {
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
ghost . remove ( ) ;
block . style . opacity = '' ;
// Only suppress the trailing click-open if the user actually dragged —
// a plain click (no movement) must still open the event.
if ( moved ) block . dataset . justResized = '1' ;
// Decide whether anything actually moved.
const oldDs = ( ev . dtstart || '' ) . slice ( 0 , 10 ) ;
if ( ! nextDs ) return ;
if ( nextDs === oldDs && nextStartMin === startMin0 ) return ;
// Snapshot the original times so we can offer an Undo.
const prevDtstart = ev . dtstart ;
const prevDtend = ev . dtend ;
const newEndMin = nextStartMin + durationMin ;
const hh = String ( Math . floor ( nextStartMin / 60 ) ) . padStart ( 2 , '0' ) ;
const mm = String ( nextStartMin % 60 ) . padStart ( 2 , '0' ) ;
const hh2 = String ( Math . floor ( newEndMin / 60 ) ) . padStart ( 2 , '0' ) ;
const mm2 = String ( ( newEndMin ) % 60 ) . padStart ( 2 , '0' ) ;
const _tz = _tzOffset ( ) ;
const newDtstart = ` ${ nextDs } T ${ hh } : ${ mm } :00 ${ _tz } ` ;
const newDtend = ` ${ nextDs } T ${ hh2 } : ${ mm2 } :00 ${ _tz } ` ;
try {
await _updateEvent ( uid , { dtstart : newDtstart , dtend : newDtend } ) ;
_render ( ) ;
_showCalUndoToast ( 'Moved event' , async ( ) => {
try {
await _updateEvent ( uid , { dtstart : prevDtstart , dtend : prevDtend } ) ;
_render ( ) ;
} catch ( err ) { console . error ( 'Undo failed:' , err ) ; }
} ) ;
} catch {
_render ( ) ;
}
} ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
} ) ;
// Drag the bottom edge of a timed block to extend / shrink the event.
// Snaps to 15-min increments; releases with a PUT to /api/calendar/events.
body . querySelectorAll ( '.cal-wk-block .cal-wk-block-resize' ) . forEach ( handle => {
handle . addEventListener ( 'mousedown' , ( e ) => {
if ( e . button !== 0 ) return ;
e . stopPropagation ( ) ;
e . preventDefault ( ) ;
const block = handle . closest ( '.cal-wk-block' ) ;
const grid = block . parentElement ;
const ds = grid . dataset . date ;
const uid = block . dataset . uid ;
const ev = _events . find ( x => x . uid === uid ) ;
if ( ! ev || ! grid || ! ds ) return ;
const startMin = ( ( ) => {
const m = ( ev . dtstart || '' ) . match ( /T(\d{2}):(\d{2})/ ) ;
return m ? parseInt ( m [ 1 ] , 10 ) * 60 + parseInt ( m [ 2 ] , 10 ) : 0 ;
} ) ( ) ;
const initialTop = parseFloat ( block . style . top || '0' ) ;
const gridRect = grid . getBoundingClientRect ( ) ;
let newEndMin = startMin ;
let resized = false ;
const onMove = ( mv ) => {
resized = true ;
const y = Math . max ( 0 , Math . min ( grid . clientHeight , mv . clientY - gridRect . top ) ) ;
// Snap to 15-min increments; enforce a 15-min minimum duration.
newEndMin = Math . max ( startMin + 15 , Math . round ( _wkPxToMin ( y ) / 15 ) * 15 ) ;
const newHeight = Math . max ( 18 , ( newEndMin - startMin ) * ( WEEK _HOUR _PX / 60 ) ) ;
block . style . height = newHeight + 'px' ;
const timeEl = block . querySelector ( '.cal-wk-block-time' ) ;
if ( timeEl ) {
const hh = String ( Math . floor ( newEndMin / 60 ) ) . padStart ( 2 , '0' ) ;
const mm = String ( newEndMin % 60 ) . padStart ( 2 , '0' ) ;
timeEl . textContent = ` ${ _fmtTime ( ev . dtstart ) } – ${ hh } : ${ mm } ` ;
}
} ;
const onUp = async ( ) => {
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
if ( resized ) block . dataset . justResized = '1' ;
if ( newEndMin === startMin ) return ;
const prevDtend = ev . dtend ;
const hh = String ( Math . floor ( newEndMin / 60 ) ) . padStart ( 2 , '0' ) ;
const mm = String ( newEndMin % 60 ) . padStart ( 2 , '0' ) ;
const newDtend = ` ${ ds } T ${ hh } : ${ mm } :00 ${ _tzOffset ( ) } ` ;
try {
await _updateEvent ( uid , { dtend : newDtend } ) ;
_render ( ) ;
_showCalUndoToast ( 'Resized event' , async ( ) => {
try {
await _updateEvent ( uid , { dtend : prevDtend } ) ;
_render ( ) ;
} catch ( err ) { console . error ( 'Undo failed:' , err ) ; }
} ) ;
} catch ( err ) {
// Roll back the visual on failure
_render ( ) ;
}
} ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
} ) ;
// Drag-to-create on empty grid: mousedown on a cell, drag down, release.
body . querySelectorAll ( '.cal-wk-grid' ) . forEach ( grid => {
grid . addEventListener ( 'mousedown' , ( e ) => {
// Don't start a drag-create when the press lands on an existing event.
if ( e . target . closest ( '.cal-wk-block' ) ) return ;
if ( e . button !== 0 ) return ;
e . preventDefault ( ) ;
const rect = grid . getBoundingClientRect ( ) ;
const ds = grid . dataset . date ;
const startY = e . clientY - rect . top ;
const ghost = document . createElement ( 'div' ) ;
ghost . className = 'cal-wk-ghost' ;
grid . appendChild ( ghost ) ;
const onMove = ( mv ) => {
const y2 = Math . max ( 0 , Math . min ( grid . clientHeight , mv . clientY - rect . top ) ) ;
const y1 = Math . min ( startY , y2 ) ;
const yEnd = Math . max ( startY , y2 ) ;
const startMin = _wkPxToMin ( y1 ) ;
const endMin = Math . max ( _wkPxToMin ( yEnd ) , startMin + 15 ) ;
ghost . style . top = ( startMin / 60 ) * WEEK _HOUR _PX + 'px' ;
ghost . style . height = ( ( endMin - startMin ) / 60 ) * WEEK _HOUR _PX + 'px' ;
ghost . dataset . start = _wkMinToHHMM ( startMin ) ;
ghost . dataset . end = _wkMinToHHMM ( endMin ) ;
ghost . textContent = ` ${ ghost . dataset . start } – ${ ghost . dataset . end } ` ;
} ;
const onUp = ( ) => {
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
const startHHMM = ghost . dataset . start ;
const endHHMM = ghost . dataset . end ;
ghost . remove ( ) ;
if ( ! startHHMM || ! endHHMM ) return ;
// Open the bespoke event form pre-filled with this slot.
_showEventFormForRange ( ds , startHHMM , endHHMM ) ;
} ;
onMove ( e ) ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
} ) ;
// Restore scroll. Default-land at WK_DEFAULT_SCROLL_HOUR the first time
// week view opens; afterwards keep the user's last position.
const _wrap = body . querySelector ( '.cal-wk-wrap' ) ;
if ( _wrap ) {
if ( _wkScrollY != null ) {
_wrap . scrollTop = _wkScrollY ;
} else if ( ! _wkScrolledOnce ) {
_wrap . scrollTop = WK _DEFAULT _SCROLL _HOUR * WEEK _HOUR _PX ;
_wkScrolledOnce = true ;
}
}
// Zoom buttons in the rail-spacer corner.
document . getElementById ( 'cal-wk-zoom-in' ) ? . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _wkZoomBy ( + 12 ) ; } ) ;
document . getElementById ( 'cal-wk-zoom-out' ) ? . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _wkZoomBy ( - 12 ) ; } ) ;
// Keyboard zoom (`+` / `-`), Ctrl/Cmd-wheel zoom — both only fire while
// we're in week view and no text input has focus.
if ( ! body . _wkZoomKeysWired ) {
body . _wkZoomKeysWired = true ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( _view !== 'week' ) return ;
const tag = ( document . activeElement ? . tagName || '' ) . toLowerCase ( ) ;
if ( tag === 'input' || tag === 'textarea' || tag === 'select' ) return ;
if ( e . key === '+' || e . key === '=' ) { e . preventDefault ( ) ; _wkZoomBy ( + 12 ) ; }
else if ( e . key === '-' || e . key === '_' ) { e . preventDefault ( ) ; _wkZoomBy ( - 12 ) ; }
else if ( e . key === '0' ) { e . preventDefault ( ) ; _wkSetZoom ( WK _PX _DEFAULT ) ; }
} ) ;
}
body . querySelector ( '.cal-wk-wrap' ) ? . addEventListener ( 'wheel' , ( e ) => {
if ( ! ( e . ctrlKey || e . metaKey ) ) return ;
e . preventDefault ( ) ;
_wkZoomBy ( e . deltaY < 0 ? + 8 : - 8 ) ;
} , { passive : false } ) ;
_updateBadge ( ) ;
}
function _showEventFormForRange ( ds , startHHMM , endHHMM ) {
// Open the new-event form, then seed the time inputs with the dragged
// range and force the details panel open so the user can see/adjust.
_showEventForm ( null , ds , ds ) ;
requestAnimationFrame ( ( ) => {
const startEl = document . getElementById ( 'cal-f-start' ) ;
const endEl = document . getElementById ( 'cal-f-end' ) ;
if ( startEl ) startEl . value = startHHMM ;
if ( endEl ) endEl . value = endHHMM ;
startEl ? . dispatchEvent ( new Event ( 'input' ) ) ;
// Auto-expand details so the time fields are visible when someone
// arrived here via drag-to-create rather than the +New button.
document . querySelector ( '.cal-form-bespoke' ) ? . classList . add ( 'is-expanded' ) ;
const details = document . getElementById ( 'cal-form-details' ) ;
if ( details ) details . setAttribute ( 'aria-hidden' , 'false' ) ;
} ) ;
}
// ── Agenda View ──
async function _renderAgenda ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const _tk = _renderToken ;
// Fetch 3 months forward from current date
const s = _ds ( _currentDate ) ;
const eDate = new Date ( _currentDate ) ; eDate . setMonth ( eDate . getMonth ( ) + 3 ) ;
const e = _ds ( eDate ) ;
await _fetchEvents ( s , e ) ;
if ( _isStaleRender ( _tk ) ) return ;
// Filter + group by date
const visible = _events . filter ( ev => ! ! _eventVisible ( ev ) )
. sort ( ( a , b ) => a . dtstart < b . dtstart ? - 1 : 1 ) ;
let h = _headerHTML ( ) + _filtersRowHTML ( ) + '<div class="cal-agenda">' ;
// Group events by local date, then always surface today (when it's inside
// the agenda window) even if it has no events, so the user can see "today".
const byDate = new Map ( ) ;
for ( const ev of visible ) {
const d = _localDateOf ( ev . dtstart ) ;
if ( ! byDate . has ( d ) ) byDate . set ( d , [ ] ) ;
byDate . get ( d ) . push ( ev ) ;
}
const today = _today ( ) ;
if ( today >= s && today <= e && ! byDate . has ( today ) ) byDate . set ( today , [ ] ) ;
const dates = [ ... byDate . keys ( ) ] . sort ( ) ;
if ( ! dates . length ) {
// Empty-state mirrors the email panel: short message + a Settings ›
// Integrations link to set up CalDAV, OR a quick "Create event" action.
h += '<div class="cal-empty" style="display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;">' +
'<span>No upcoming events</span>' +
'<span style="opacity:0.7;font-size:11px;">' +
'<a href="#" data-cal-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings › Integrations</a>' +
' · ' +
'<a href="#" data-cal-create-event="1" style="color:var(--accent,var(--red));text-decoration:underline;">Create event</a>' +
'</span>' +
'</div>' ;
} else {
for ( const date of dates ) {
const evs = byDate . get ( date ) ;
const todayBadge = ( date === today ) ? ' <span class="cal-agenda-today-badge">Today</span>' : '' ;
h += ` <div class="cal-agenda-day ${ date === today ? ' is-today' : '' } "><div class="cal-agenda-date"> ${ _fmtDate ( date ) } ${ todayBadge } </div> ` ;
if ( ! evs . length ) {
h += '<div class="cal-agenda-empty">No events</div>' ;
}
for ( const ev of evs ) {
const t = ev . all _day ? 'All day' : _fmtTime ( ev . dtstart ) + ' – ' + _fmtTime ( ev . dtend ) ;
const _typeTag = ev . event _type
? ` <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> `
: '' ;
const _impMark = ev . importance === 'critical' ? '<span style="color:var(--red);margin-right:4px" title="critical">!!</span>'
: ev . importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:4px" title="high">!</span>' : '' ;
h += ` <div class="cal-agenda-event" data-uid=" ${ _e ( ev . uid ) } ">
< div class = "cal-event-dot" style = "background:${_calColor(ev)}" > < / d i v >
< div class = "cal-event-info" >
< div class = "cal-event-name" > $ { _impMark } $ { _e ( ev . summary ) } $ { _typeTag } < / d i v >
< div class = "cal-event-time" > $ { t } $ { ev . location ? ' · ' + _locHTML ( ev . location ) : '' } < / d i v >
< / d i v >
< button class = "cal-event-more" data - uid = "${_e(ev.uid)}" title = "More" > $ { _moreIcon } < / b u t t o n >
< / d i v > ` ;
}
h += '</div>' ;
}
}
h += '</div>' ;
// If the user grabbed the quick-add field mid-fetch, skip the swap (which
// would destroy the focused input + drop the keyboard) and defer until blur.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
body . innerHTML = h ;
_wireAll ( body ) ;
_wireQuickDelete ( body ) ;
body . querySelectorAll ( '.cal-agenda-event' ) . forEach ( el => el . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-event-more' ) ) return ;
const ev = _events . find ( e => e . uid === el . dataset . uid ) ;
if ( ev ) _showEventForm ( ev ) ;
} ) ) ;
// Empty-state links: Settings › Integrations + Create event.
body . querySelector ( '[data-cal-open-settings]' ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
closeCalendar ( ) ;
const modal = document . getElementById ( 'settings-modal' ) ;
if ( modal ) {
modal . classList . remove ( 'hidden' ) ;
const tab = modal . querySelector ( '[data-settings-tab="integrations"]' ) ;
if ( tab ) tab . click ( ) ;
}
} ) ;
body . querySelector ( '[data-cal-create-event]' ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
_showEventForm ( null ) ;
} ) ;
_updateBadge ( ) ;
}
// ── Search View ──
async function _renderSearch ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
// Search across all events in pool (no fetch needed — use what we have)
const q = _searchQuery . toLowerCase ( ) ;
const results = Object . values ( _allEvents )
. filter ( ev => ! ! _eventVisible ( ev ) )
. filter ( ev =>
( ev . summary || '' ) . toLowerCase ( ) . includes ( q ) ||
( ev . description || '' ) . toLowerCase ( ) . includes ( q ) ||
( ev . location || '' ) . toLowerCase ( ) . includes ( q )
)
. sort ( ( a , b ) => a . dtstart < b . dtstart ? - 1 : 1 ) ;
let h = _headerHTML ( ) + _filtersRowHTML ( ) + '<div class="cal-search-results">' ;
h += ` <div class="cal-search-count"> ${ results . length } result ${ results . length !== 1 ? 's' : '' } for " ${ _e ( _searchQuery ) } "</div> ` ;
if ( ! results . length ) {
h += '<div class="cal-empty">No events match your search</div>' ;
} else {
for ( const ev of results ) {
const evDate = _localDateOf ( ev . dtstart ) ;
const t = ev . all _day ? 'All day' : _fmtTime ( ev . dtstart ) + ' – ' + _fmtTime ( ev . dtend ) ;
h += ` <div class="cal-agenda-event" data-uid=" ${ _e ( ev . uid ) } ">
< div class = "cal-event-dot" style = "background:${_calColor(ev)}" > < / d i v >
< div class = "cal-event-info" >
< div class = "cal-event-name" > $ { _e ( ev . summary ) } < / d i v >
< div class = "cal-event-time" > $ { _fmtDate ( evDate ) } · $ { t } $ { ev . location ? ' · ' + _locHTML ( ev . location ) : '' } < / d i v >
< / d i v >
< button class = "cal-event-more" data - uid = "${_e(ev.uid)}" title = "More" > $ { _moreIcon } < / b u t t o n >
< / d i v > ` ;
}
}
h += '</div>' ;
// If the user grabbed the quick-add field mid-fetch, skip the swap (which
// would destroy the focused input + drop the keyboard) and defer until blur.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
body . innerHTML = h ;
_wireAll ( body ) ;
_wireQuickDelete ( body ) ;
body . querySelectorAll ( '.cal-agenda-event' ) . forEach ( el => el . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-event-more' ) ) return ;
const ev = _allEvents [ el . dataset . uid ] ;
if ( ev ) _showEventForm ( ev ) ;
} ) ) ;
// Focus search input after re-render
const searchInput = document . getElementById ( 'cal-search' ) ;
if ( searchInput && document . activeElement !== searchInput ) {
searchInput . focus ( ) ;
searchInput . setSelectionRange ( searchInput . value . length , searchInput . value . length ) ;
}
}
// ── Year View ──
async function _renderYear ( ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const _tk = _renderToken ;
const y = _currentDate . getFullYear ( ) ;
await _fetchEvents ( ` ${ y } -01-01 ` , ` ${ y + 1 } -01-01 ` ) ;
if ( _isStaleRender ( _tk ) ) return ;
const today = _today ( ) ;
let h = _headerHTML ( ) + _filtersRowHTML ( ) + '<div class="cal-year">' ;
for ( let m = 0 ; m < 12 ; m ++ ) {
h += ` <div class="cal-year-month" data-month=" ${ m } "><div class="cal-year-month-title"> ${ MON _SHORT [ m ] } </div> ` ;
h += '<div class="cal-year-grid">' ;
for ( const wd of [ 'M' , 'T' , 'W' , 'T' , 'F' , 'S' , 'S' ] ) h += ` <div class="cal-year-wd"> ${ wd } </div> ` ;
const first = new Date ( y , m , 1 ) ;
const dow = ( first . getDay ( ) + 6 ) % 7 ;
const daysInMonth = new Date ( y , m + 1 , 0 ) . getDate ( ) ;
for ( let p = 0 ; p < dow ; p ++ ) h += '<div class="cal-year-cell"></div>' ;
for ( let d = 1 ; d <= daysInMonth ; d ++ ) {
const ds = ` ${ y } - ${ String ( m + 1 ) . padStart ( 2 , '0' ) } - ${ String ( d ) . padStart ( 2 , '0' ) } ` ;
const evs = _eventsForDay ( ds ) ;
const isToday = ds === today ;
let cls = 'cal-year-cell cal-year-day' ;
if ( isToday ) cls += ' cal-year-today' ;
if ( evs . length ) cls += ' cal-year-has' ;
h += ` <div class=" ${ cls } " data-date=" ${ ds } " title=" ${ evs . length ? evs . length + ' event' + ( evs . length > 1 ? 's' : '' ) : '' } "> ${ d } </div> ` ;
}
h += '</div></div>' ;
}
h += '</div>' ;
// If the user grabbed the quick-add field mid-fetch, skip the swap (which
// would destroy the focused input + drop the keyboard) and defer until blur.
if ( _qaTyping ( ) ) { _renderPending = true ; return ; }
body . innerHTML = h ;
_wireAll ( body ) ;
// Month box click → jump to month view (but not when clicking a specific day)
body . querySelectorAll ( '.cal-year-month' ) . forEach ( el => {
el . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-year-day' ) ) return ;
const m = parseInt ( el . dataset . month ) ;
_currentDate = new Date ( _currentDate . getFullYear ( ) , m , 1 ) ;
_view = 'month' ;
_render ( ) ;
} ) ;
} ) ;
// Day click in year view → jump to month
body . querySelectorAll ( '.cal-year-day' ) . forEach ( el => {
el . addEventListener ( 'click' , ( ) => {
const d = el . dataset . date ;
_currentDate = new Date ( d + 'T00:00:00' ) ;
_selectedDay = d ;
_view = 'month' ;
_render ( ) ;
} ) ;
} ) ;
_updateBadge ( ) ;
}
// ── Shared HTML builders ──
function _dayDetailHTML ( dateStr ) {
const isToday = dateStr === _today ( ) ;
// Search lives inside the day panel now — typing filters the panel
// body to global search results instead of just this day's events.
// Magnifying-glass icon inside the search field via a wrapper + padding-left.
const searchInput = ` <div class="cal-search-wrap">
< 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" / > < / s v g >
< input type = "search" class = "cal-search-input cal-day-search" id = "cal-search" placeholder = "Search all events…" value = "${_e(_searchQuery)}" / >
< / d i v > ` ;
let h = ` <div class="cal-splitter" role="separator" aria-orientation="horizontal" tabindex="0" title="Drag to resize"><div class="cal-splitter-grip"></div></div>
< div class = "cal-day-detail" >
$ { searchInput }
< div class = "cal-detail-header" >
< span > $ { _fmtDate ( dateStr ) } $ { isToday ? ' <span style="color:var(--accent, var(--red));font-weight:600;">(Today)</span>' : '' } < / s p a n >
< 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" > + < / s p a n > < s p a n c l a s s = " c a l - a d d - l a b e l " > N e w < / s p a n > < / b u t t o n >
< / d i v > ` ;
if ( _searchQuery ) {
const q = _searchQuery . toLowerCase ( ) ;
const results = _events
. filter ( _eventVisible )
. filter ( e =>
( e . summary || '' ) . toLowerCase ( ) . includes ( q ) ||
( e . description || '' ) . toLowerCase ( ) . includes ( q ) ||
( e . location || '' ) . toLowerCase ( ) . includes ( q )
)
. sort ( ( a , b ) => ( a . dtstart || '' ) . localeCompare ( b . dtstart || '' ) ) ;
h += ` <div class="cal-day-search-meta"> ${ results . length } result ${ results . length !== 1 ? 's' : '' } </div> ` ;
if ( ! results . length ) {
h += '<div class="cal-empty">No events match</div>' ;
} else {
results . forEach ( ev => {
const date = ev . all _day ? ev . dtstart : _localDateOf ( ev . dtstart ) ;
const t = ev . all _day ? 'All day' : _fmtTime ( ev . dtstart ) + ' – ' + _fmtTime ( ev . dtend ) ;
const bgStyle = _calItemBgStyle ( ev ) ;
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)}" > < / d i v >
< div class = "cal-event-info" >
< div class = "cal-event-name" > $ { _e ( ev . summary ) } < / d i v >
< div class = "cal-event-time" > $ { _fmtDate ( date ) } · $ { t } < / d i v >
$ { ev . location ? ` <div class="cal-event-loc"> ${ _locHTML ( ev . location ) } </div> ` : '' }
< / d i v >
< button class = "cal-event-more" data - uid = "${_e(ev.uid)}" title = "More" > $ { _moreIcon } < / b u t t o n >
< / d i v > ` ;
} ) ;
}
return h + '</div>' ;
}
const evs = _eventsForDay ( dateStr ) ;
if ( ! evs . length ) h += '<div class="cal-empty">No events</div>' ;
else evs . forEach ( ev => {
const t = ev . all _day ? 'All day' : _fmtTime ( ev . dtstart ) + ' – ' + _fmtTime ( ev . dtend ) ;
const _bgStyle = _calItemBgStyle ( ev ) ;
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> ` ;
} ) ;
return h + '</div>' ;
}
// ── Wire all common listeners ──
function _wireAll ( body ) {
// ── Day-detail splitter (drag to resize) ────────────────────────
// Restores the saved height each render so the user's choice survives
// navigation between months/weeks. Drag adjusts a single CSS variable
// on #cal-body — the grid clamps its height and the day-detail expands
// / contracts accordingly via CSS rules.
try {
const calBody = document . getElementById ( 'cal-body' ) ;
const splitter = body . querySelector ( '.cal-splitter' ) ;
if ( calBody && splitter ) {
// Only seed from localStorage on the first wire-up. Subsequent
// renders (every keystroke when the user is typing in search)
// would otherwise clobber an in-progress focus-expand and bounce
// the day-detail pane up and down on every character.
const alreadySet = calBody . style . getPropertyValue ( '--cal-detail-h' ) ;
if ( ! alreadySet ) {
const saved = parseInt ( localStorage . getItem ( 'odysseus.cal.detailH' ) || '0' , 10 ) ;
if ( saved && saved > 80 ) calBody . style . setProperty ( '--cal-detail-h' , saved + 'px' ) ;
}
let startY = 0 , startH = 240 , dragging = false ;
const onMove = ( ev ) => {
if ( ! dragging ) return ;
const y = ev . touches ? ev . touches [ 0 ] . clientY : ev . clientY ;
// Drag UP (smaller y) → bigger day-detail. Allow the pane to grow
// all the way to the top of the visible viewport so the user can
// hide the calendar entirely. We leave ~24px headroom so the
// splitter handle itself stays grabbable to drag back down.
const vh = ( window . visualViewport ? . height ) || window . innerHeight ;
const newH = Math . max ( 40 , Math . min ( vh - 24 , startH + ( startY - y ) ) ) ;
calBody . style . setProperty ( '--cal-detail-h' , newH + 'px' ) ;
} ;
const onUp = ( ) => {
if ( ! dragging ) return ;
dragging = false ;
splitter . classList . remove ( 'cal-splitter-dragging' ) ;
document . removeEventListener ( 'pointermove' , onMove ) ;
document . removeEventListener ( 'pointerup' , onUp ) ;
document . removeEventListener ( 'touchmove' , onMove ) ;
document . removeEventListener ( 'touchend' , onUp ) ;
const cur = calBody . style . getPropertyValue ( '--cal-detail-h' ) ;
const px = parseInt ( cur , 10 ) ;
if ( px ) { try { localStorage . setItem ( 'odysseus.cal.detailH' , String ( px ) ) ; } catch { } }
} ;
const onDown = ( ev ) => {
ev . preventDefault ( ) ;
dragging = true ;
splitter . classList . add ( 'cal-splitter-dragging' ) ;
startY = ev . touches ? ev . touches [ 0 ] . clientY : ev . clientY ;
const detail = body . querySelector ( '.cal-day-detail' ) ;
startH = detail ? detail . getBoundingClientRect ( ) . height : 240 ;
document . addEventListener ( 'pointermove' , onMove ) ;
document . addEventListener ( 'pointerup' , onUp , { once : false } ) ;
document . addEventListener ( 'touchmove' , onMove , { passive : false } ) ;
document . addEventListener ( 'touchend' , onUp ) ;
} ;
splitter . addEventListener ( 'pointerdown' , onDown ) ;
splitter . addEventListener ( 'touchstart' , onDown , { passive : false } ) ;
// Double-tap (or double-click) the splitter to reset the day-detail
// pane to its CSS default height.
let _lastTap = 0 ;
const resetSplit = ( ) => {
calBody . style . removeProperty ( '--cal-detail-h' ) ;
try { localStorage . removeItem ( 'odysseus.cal.detailH' ) ; } catch { }
} ;
splitter . addEventListener ( 'dblclick' , resetSplit ) ;
splitter . addEventListener ( 'touchend' , ( ) => {
const now = Date . now ( ) ;
if ( now - _lastTap < 320 ) {
resetSplit ( ) ;
_lastTap = 0 ;
} else {
_lastTap = now ;
}
} ) ;
}
} catch { }
// ── Quick-add input ─────────────────────────────────────────────
const _qaInput = document . getElementById ( 'cal-quickadd' ) ;
const _qaStatus = document . getElementById ( 'cal-quickadd-status' ) ;
if ( _qaInput && ! _qaInput . _wired ) {
_qaInput . _wired = true ;
const _submitQA = async ( ) => {
const text = _qaInput . value . trim ( ) ;
if ( ! text || _qaSubmitting ) return ;
// Use a flag rather than `disabled` to block double-submit — disabling
// the input blurs it, which would flush a deferred render and wipe the
// spinner's container mid-parse.
_qaSubmitting = true ;
// Whirlpool spinner after the text — but only once parsing has run long
// enough to be worth showing (~250ms), so fast parses don't flash it.
let _qaSpin = null ;
let _qaSpinTimer = null ;
if ( _qaStatus ) {
_qaStatus . textContent = '' ;
try {
const sp = ( await import ( './spinner.js' ) ) . default ;
_qaSpinTimer = setTimeout ( ( ) => {
_qaSpin = sp . createWhirlpool ( 14 ) ;
_qaSpin . element . style . cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;left:-2px;margin-left:4px;' ;
_qaStatus . appendChild ( _qaSpin . element ) ;
} , 250 ) ;
} catch {
_qaSpinTimer = setTimeout ( ( ) => { if ( _qaStatus ) _qaStatus . textContent = 'parsing…' ; } , 250 ) ;
}
}
try {
const tz = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone || '' ;
const res = await fetch ( ` ${ API _BASE } /api/calendar/quick-parse ` , {
method : 'POST' ,
credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { text , tz } ) ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok || ! data . ok ) {
if ( _qaStatus ) _qaStatus . textContent = '' ;
uiModule . showError ( 'Quick-add: ' + ( data . error || data . detail || ` HTTP ${ res . status } ` ) ) ;
return ;
}
// Open the bespoke event form, then push the parsed fields in.
const ev = data . event ;
const ds = ( ev . dtstart || '' ) . slice ( 0 , 10 ) ;
const de = ( ev . dtend || '' ) . slice ( 0 , 10 ) || ds ;
_showEventForm ( null , ds , de ) ;
requestAnimationFrame ( ( ) => {
const set = ( id , v ) => { const el = document . getElementById ( id ) ; if ( el && v != null ) el . value = v ; } ;
set ( 'cal-f-sum' , ev . summary ) ;
set ( 'cal-f-loc' , ev . location ) ;
set ( 'cal-f-desc' , ev . description ) ;
if ( ev . all _day ) {
const ad = document . getElementById ( 'cal-f-allday' ) ;
if ( ad && ! ad . checked ) { ad . checked = true ; ad . dispatchEvent ( new Event ( 'change' ) ) ; }
} else {
const t1 = ( ev . dtstart || '' ) . match ( /T(\d{2}:\d{2})/ ) ;
const t2 = ( ev . dtend || '' ) . match ( /T(\d{2}:\d{2})/ ) ;
if ( t1 ) set ( 'cal-f-start' , t1 [ 1 ] ) ;
if ( t2 ) set ( 'cal-f-end' , t2 [ 1 ] ) ;
document . getElementById ( 'cal-f-start' ) ? . dispatchEvent ( new Event ( 'input' ) ) ;
}
// Make sure the details panel is open so the user can verify time.
document . querySelector ( '.cal-form-bespoke' ) ? . classList . add ( 'is-expanded' ) ;
const det = document . getElementById ( 'cal-form-details' ) ;
if ( det ) det . setAttribute ( 'aria-hidden' , 'false' ) ;
// Trigger Apple-Maps link sync now that location is filled in.
document . getElementById ( 'cal-f-loc' ) ? . dispatchEvent ( new Event ( 'input' ) ) ;
} ) ;
// Reset for next quick add.
_qaInput . value = '' ;
} catch ( e ) {
uiModule . showError ( 'Quick-add failed: ' + e . message ) ;
} finally {
_qaSubmitting = false ;
clearTimeout ( _qaSpinTimer ) ;
if ( _qaSpin ) { try { _qaSpin . destroy ( ) ; } catch { } _qaSpin . element ? . remove ( ) ; }
if ( _qaStatus ) _qaStatus . textContent = '' ;
}
} ;
_qaInput . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) { e . preventDefault ( ) ; _submitQA ( ) ; }
else if ( e . key === 'Escape' ) { _qaInput . value = '' ; _qaInput . blur ( ) ; }
} ) ;
// Flush any render we deferred while the field was focused.
_qaInput . addEventListener ( 'blur' , ( ) => {
if ( _renderPending ) { _renderPending = false ; _render ( ) ; }
} ) ;
}
// After a background re-render (e.g. /events fetch returning), restore
// focus + caret + value so the user can keep typing uninterrupted.
if ( _qaInput && _qaPendingRestore ) {
_qaInput . value = _qaPendingRestore . value ;
_qaInput . focus ( ) ;
try {
_qaInput . setSelectionRange ( _qaPendingRestore . selStart , _qaPendingRestore . selEnd ) ;
} catch { }
_qaPendingRestore = null ;
}
// Q anywhere on the page (when not typing elsewhere) focuses quick-add.
if ( ! body . _qaShortcutWired ) {
body . _qaShortcutWired = true ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( ! _open ) return ;
if ( e . key !== 'q' && e . key !== 'Q' ) return ;
const tag = ( document . activeElement ? . tagName || '' ) . toLowerCase ( ) ;
if ( tag === 'input' || tag === 'textarea' || tag === 'select' ) return ;
const inp = document . getElementById ( 'cal-quickadd' ) ;
if ( inp ) { e . preventDefault ( ) ; inp . focus ( ) ; inp . select ( ) ; }
} ) ;
}
// Pinch zoom on the calendar body changes the view granularity:
// year ⇆ month ⇆ week. Pinch IN zooms to a tighter view, pinch OUT
// zooms out. Fires once per gesture so a strong pinch doesn't skip
// straight from year to week (the user gets one step at a time and
// can release-and-pinch again).
if ( body && ! body . _pinchZoomWired ) {
body . _pinchZoomWired = true ;
let pinchStart = 0 , pinchActive = false , pinchFired = false ;
const dist = ( ts ) => Math . hypot ( ts [ 0 ] . clientX - ts [ 1 ] . clientX , ts [ 0 ] . clientY - ts [ 1 ] . clientY ) ;
body . addEventListener ( 'touchstart' , ( e ) => {
if ( e . touches . length === 2 ) {
pinchStart = dist ( e . touches ) ;
pinchActive = true ;
pinchFired = false ;
}
} , { passive : true } ) ;
body . addEventListener ( 'touchmove' , ( e ) => {
if ( ! pinchActive || pinchFired || e . touches . length !== 2 ) return ;
const ratio = dist ( e . touches ) / pinchStart ;
if ( ratio > 1.35 ) { _zoomView ( + 1 ) ; pinchFired = true ; }
else if ( ratio < 0.7 ) { _zoomView ( - 1 ) ; pinchFired = true ; }
} , { passive : true } ) ;
body . addEventListener ( 'touchend' , ( e ) => {
if ( e . touches . length < 2 ) pinchActive = false ;
} , { passive : true } ) ;
}
// Touch swipe ← → on the calendar body switches months/weeks/etc. Only
// fires when the swipe is clearly horizontal so vertical scrolling inside
// long event lists isn't hijacked. Attached fresh on each render via
// _wireAll → existing prev/next handlers do the actual navigation.
if ( body && ! body . _swipeWired ) {
body . _swipeWired = true ;
let _sx = 0 , _sy = 0 , _t0 = 0 , _tracking = false ;
body . addEventListener ( 'touchstart' , ( e ) => {
if ( ! e . touches || e . touches . length !== 1 ) return ;
_sx = e . touches [ 0 ] . clientX ;
_sy = e . touches [ 0 ] . clientY ;
_t0 = Date . now ( ) ;
_tracking = true ;
} , { passive : true } ) ;
body . addEventListener ( 'touchend' , ( e ) => {
if ( ! _tracking ) return ;
_tracking = false ;
const t = e . changedTouches && e . changedTouches [ 0 ] ;
if ( ! t ) return ;
const dx = t . clientX - _sx ;
const dy = t . clientY - _sy ;
const dt = Date . now ( ) - _t0 ;
// Threshold: at least 50px horizontal, dominant axis is horizontal,
// and reasonably quick (under 600ms) so it feels intentional.
if ( Math . abs ( dx ) < 50 ) return ;
if ( Math . abs ( dx ) < Math . abs ( dy ) * 1.3 ) return ;
if ( dt > 600 ) return ;
if ( dx < 0 ) document . getElementById ( 'cal-next' ) ? . click ( ) ;
else document . getElementById ( 'cal-prev' ) ? . click ( ) ;
} , { passive : true } ) ;
}
document . getElementById ( 'cal-prev' ) ? . addEventListener ( 'click' , ( ) => {
_slideDir = - 1 ;
if ( _view === 'year' ) _currentDate = new Date ( _currentDate . getFullYear ( ) - 1 , 0 , 1 ) ;
else if ( _view === 'week' ) _currentDate . setDate ( _currentDate . getDate ( ) - 7 ) ;
else if ( _view === 'agenda' ) _currentDate . setDate ( _currentDate . getDate ( ) - 30 ) ;
else _currentDate = new Date ( _currentDate . getFullYear ( ) , _currentDate . getMonth ( ) - 1 , 1 ) ;
// Keep a day selected in month/week so the day-detail panel — which hosts
// the search box — stays available (otherwise browsing hides search).
_selectedDay = ( _view === 'month' || _view === 'week' ) ? _ds ( _currentDate ) : null ;
_render ( ) ;
} ) ;
document . getElementById ( 'cal-next' ) ? . addEventListener ( 'click' , ( ) => {
_slideDir = 1 ;
if ( _view === 'year' ) _currentDate = new Date ( _currentDate . getFullYear ( ) + 1 , 0 , 1 ) ;
else if ( _view === 'week' ) _currentDate . setDate ( _currentDate . getDate ( ) + 7 ) ;
else if ( _view === 'agenda' ) _currentDate . setDate ( _currentDate . getDate ( ) + 30 ) ;
else _currentDate = new Date ( _currentDate . getFullYear ( ) , _currentDate . getMonth ( ) + 1 , 1 ) ;
_selectedDay = ( _view === 'month' || _view === 'week' ) ? _ds ( _currentDate ) : null ;
_render ( ) ;
} ) ;
document . getElementById ( 'cal-today' ) ? . addEventListener ( 'click' , ( ) => { _currentDate = new Date ( ) ; _selectedDay = _today ( ) ; _render ( ) ; } ) ;
document . getElementById ( 'cal-settings' ) ? . addEventListener ( 'click' , ( ) => _showCalSettings ( ) ) ;
document . getElementById ( 'cal-sync' ) ? . addEventListener ( 'click' , async ( ) => {
// Visible feedback: toggle a CSS class on the button so the spin runs
// even if the network round-trip is too fast to perceive. We hold it
// for at least 700ms (one full rotation) AND for as long as the actual
// fetch is in flight, then clear. Previously `await _render()`
// resolved instantly because _render is synchronous, so the spinner
// was set→cleared in the same tick and you saw nothing.
const btn = document . getElementById ( 'cal-sync' ) ;
btn ? . classList . add ( 'cal-syncing' ) ;
window . _calSyncing = true ;
_allEvents = { } ;
_fetchedRanges = [ ] ;
localStorage . removeItem ( LS _KEY ) ;
// Compute the visible range and force-refetch — _render() kicks off
// a fetch internally but doesn't return a promise, so we await our
// own one to actually serialize on the network.
const _range = ( _view === 'year' )
? [ ` ${ _currentDate . getFullYear ( ) } -01-01 ` , ` ${ _currentDate . getFullYear ( ) + 1 } -01-01 ` ]
: ( _view === 'week' ) ? _weekRange ( _currentDate ) : _monthRange ( _currentDate ) ;
const minSpin = new Promise ( r => setTimeout ( r , 700 ) ) ;
try {
await Promise . all ( [
_fetchEvents ( _range [ 0 ] , _range [ 1 ] , /*force*/ true ) . catch ( ( ) => { } ) ,
minSpin ,
] ) ;
} finally {
window . _calSyncing = false ;
// Flash a checkmark for ~900ms. Drive it through a flag the toolbar
// template reads (not a one-off innerHTML on the button), so a stray
// _render() — the calendar re-renders mid-flow — can't wipe it. Same
// reason the spin is flag-driven.
window . _calSyncDone = true ;
_render ( ) ;
setTimeout ( ( ) => {
window . _calSyncDone = false ;
if ( _open ) _render ( ) ;
} , 900 ) ;
if ( uiModule ? . showToast ) uiModule . showToast ( 'Calendar refreshed' ) ;
}
} ) ;
// Brief spin on the "+" glyph before the new-event form opens. The
// glyph already rotates on hover (desktop). On mobile there's no
// hover, so play the rotation on tap as a quick affordance.
const _addClick = ( e , openFn ) => {
if ( window . innerWidth <= 768 ) {
const plus = e . currentTarget . querySelector ( '.cal-add-plus' ) ;
if ( plus ) {
plus . classList . add ( 'cal-add-spinning' ) ;
setTimeout ( ( ) => plus . classList . remove ( 'cal-add-spinning' ) , 360 ) ;
}
setTimeout ( openFn , 220 ) ;
} else {
openFn ( ) ;
}
} ;
// If the user typed in quick-add but pressed "+ New" instead of Enter, treat
// it as a quick-add (parse the text) rather than opening a blank event — a
// common mix-up since the two controls sit side by side.
const _tryQuickAddFromButton = ( ) => {
const qa = document . getElementById ( 'cal-quickadd' ) ;
if ( qa && qa . value . trim ( ) ) {
qa . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) ) ;
return true ;
}
return false ;
} ;
document . getElementById ( 'cal-add' ) ? . addEventListener ( 'click' , ( e ) => _addClick ( e , ( ) => { if ( ! _tryQuickAddFromButton ( ) ) _showEventForm ( null , _selectedDay || _today ( ) ) ; } ) ) ;
// Solo "+" on the day-detail header: no spin (the small round button
// doesn't look good rotating in place — open the form immediately).
document . getElementById ( 'cal-add-day' ) ? . addEventListener ( 'click' , ( ) => { if ( ! _tryQuickAddFromButton ( ) ) _showEventForm ( null , _selectedDay ) ; } ) ;
// Mobile: relocate the toolbar's +New pill so it sits NEXT TO the
// quick-add row (not inside it — the row has its own border/background
// that makes embedded buttons look like part of the input field).
// Wrap the row and button in a flex container so they share one line.
if ( window . innerWidth <= 768 ) {
const addBtn = document . getElementById ( 'cal-add' ) ;
const qaRow = document . getElementById ( 'cal-quickadd-row' ) ;
if ( addBtn && qaRow ) {
let wrap = qaRow . parentElement ;
if ( ! wrap ? . classList . contains ( 'cal-quickadd-wrap' ) ) {
wrap = document . createElement ( 'div' ) ;
wrap . className = 'cal-quickadd-wrap' ;
qaRow . parentElement ? . insertBefore ( wrap , qaRow ) ;
wrap . appendChild ( qaRow ) ;
}
if ( addBtn . parentElement !== wrap ) wrap . appendChild ( addBtn ) ;
}
}
// Search input — re-render rebuilds the day-detail DOM on each keystroke,
// so refocus and restore caret position to keep typing smooth.
const searchInput = document . getElementById ( 'cal-search' ) ;
if ( searchInput ) {
if ( document . activeElement ? . id === 'cal-search' ) {
// First call after a re-render: refocus and place caret at end.
searchInput . focus ( ) ;
const len = searchInput . value . length ;
try { searchInput . setSelectionRange ( len , len ) ; } catch { }
}
searchInput . addEventListener ( 'input' , ( e ) => {
_searchQuery = e . target . value . trim ( ) ;
// Partial update: swap only the search results inside the day-detail
// panel, leaving the search input element itself in place. A full
// _render() destroys the input via innerHTML, and on iOS the
// keyboard dismisses even if a brand-new input is focused
// synchronously after. Keeping the same input element across
// keystrokes is the only way to keep the keyboard up.
_updateDaySearchResults ( ) ;
} ) ;
// Mobile: when the search input gains focus the on-screen keyboard
// pops up. Expand the day-detail pane to (near) the visible viewport
// height so the search bar sits at the top of the screen, well above
// the keyboard, instead of staying squashed behind it.
searchInput . addEventListener ( 'focus' , ( ) => {
if ( window . innerWidth > 768 ) return ;
const calBody = document . getElementById ( 'cal-body' ) ;
if ( ! calBody ) return ;
const vh = ( window . visualViewport ? . height ) || window . innerHeight ;
const target = vh - 24 ;
// Skip if already expanded — every keystroke triggers a re-render
// which re-focuses the input. Re-running this on each keystroke
// would shove the layout around as the user types.
const cur = parseInt ( calBody . style . getPropertyValue ( '--cal-detail-h' ) , 10 ) || 0 ;
if ( cur >= target - 24 ) return ;
calBody . style . setProperty ( '--cal-detail-h' , target + 'px' ) ;
} ) ;
}
body . querySelectorAll ( '.cal-view-btn' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
_view = b . dataset . view ;
_searchQuery = '' ;
_selectedDay = null ;
// Switching to Agenda always lands on today so you see "what's coming
// up" rather than wherever you happened to be browsing.
if ( _view === 'agenda' ) _currentDate = new Date ( ) ;
_render ( ) ;
} ) ) ;
body . querySelector ( '#cal-filter-toggle' ) ? . addEventListener ( 'click' , ( ) => {
_filtersCollapsed = ! _filtersCollapsed ;
localStorage . setItem ( 'cal-filters-collapsed' , _filtersCollapsed ? '1' : '0' ) ;
_render ( ) ;
} ) ;
body . querySelectorAll ( '.cal-filter-item' ) . forEach ( it => it . addEventListener ( 'click' , ( e ) => {
const href = it . dataset . href ;
const type = it . dataset . type ;
if ( href ) {
// Solo-filter: click = show only this calendar; click again = show all.
// Shift/Ctrl+click = toggle individually (legacy hide/show).
const allHrefs = Array . from ( body . querySelectorAll ( '.cal-filter-item[data-href]' ) ) . map ( el => el . dataset . href ) ;
if ( e . shiftKey || e . ctrlKey || e . metaKey ) {
_hiddenCals . has ( href ) ? _hiddenCals . delete ( href ) : _hiddenCals . add ( href ) ;
} else {
const soloed = ! _hiddenCals . has ( href ) && allHrefs . every ( h => h === href || _hiddenCals . has ( h ) ) ;
if ( soloed ) {
_hiddenCals . clear ( ) ;
} else {
_hiddenCals . clear ( ) ;
allHrefs . forEach ( h => { if ( h !== href ) _hiddenCals . add ( h ) ; } ) ;
}
}
} else if ( type ) {
// "!" chip toggles a separate "only important" axis — clicking it
// doesn't solo-hide other categories the way a normal type chip does.
if ( type === '!' ) {
_onlyImportant = ! _onlyImportant ;
// Clear category hides so importance becomes the active filter.
if ( _onlyImportant ) _hiddenTypes . clear ( ) ;
} else {
const allTypes = Array . from ( body . querySelectorAll ( '.cal-filter-item[data-type]' ) )
. map ( el => el . dataset . type )
. filter ( t => t !== '!' ) ;
// Engaging a category filter cancels "only important" so it doesn't
// silently keep filtering on top.
_onlyImportant = false ;
if ( e . shiftKey || e . ctrlKey || e . metaKey ) {
_hiddenTypes . has ( type ) ? _hiddenTypes . delete ( type ) : _hiddenTypes . add ( type ) ;
} else {
const soloed = ! _hiddenTypes . has ( type ) && allTypes . every ( t => t === type || _hiddenTypes . has ( t ) ) ;
if ( soloed ) {
_hiddenTypes . clear ( ) ;
} else {
_hiddenTypes . clear ( ) ;
allTypes . forEach ( t => { if ( t !== type ) _hiddenTypes . add ( t ) ; } ) ;
}
}
}
}
_render ( ) ;
} ) ) ;
body . querySelectorAll ( '.cal-day[data-date]' ) . forEach ( cell => cell . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-event-item,.cal-multiday' ) ) return ;
const d = cell . dataset . date ;
// First click on a day: select it. Second click on the same already-
// selected day: open the new-event form pre-filled with that date.
if ( _selectedDay === d ) {
_showEventForm ( null , d ) ;
return ;
}
_selectedDay = d ;
_render ( ) ;
} ) ) ;
body . querySelectorAll ( '.cal-event-item' ) . forEach ( it => it . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.cal-event-more' ) ) return ;
const ev = _events . find ( e => e . uid === it . dataset . uid ) ;
if ( ev ) _showEventForm ( ev ) ;
} ) ) ;
_wireQuickDelete ( body ) ;
// Drag
body . querySelectorAll ( '[draggable="true"][data-uid]' ) . forEach ( el => {
el . addEventListener ( 'dragstart' , ( e ) => {
_dragUid = el . dataset . uid ;
e . dataTransfer . effectAllowed = 'move' ;
el . classList . add ( 'cal-dragging' ) ;
} ) ;
el . addEventListener ( 'dragend' , ( ) => {
el . classList . remove ( 'cal-dragging' ) ;
_dragUid = null ;
body . querySelectorAll ( '.cal-drag-over' ) . forEach ( d => d . classList . remove ( 'cal-drag-over' ) ) ;
} ) ;
} ) ;
// Helper — find the day cell directly under the cursor at (x,y). Reading
// it from the cursor is more reliable than trusting whichever cell fired
// the `drop` event: if the user releases over a nested event item or
// multi-day bar, the drop fires on the inner element and the calling
// cell's `data-date` may be the wrong row.
const _cellAtPoint = ( x , y ) => {
const stack = document . elementsFromPoint ? document . elementsFromPoint ( x , y ) : [ document . elementFromPoint ( x , y ) ] ;
for ( const el of stack ) {
if ( ! el || ! el . closest ) continue ;
// Prefer the month-view day cell, fall back to any data-date target
// (e.g. week-view column) so week-view drag still works.
const dayCell = el . closest ( '.cal-day[data-date]' ) ;
if ( dayCell ) return dayCell ;
const anyCell = el . closest ( '[data-date]' ) ;
if ( anyCell ) return anyCell ;
}
return null ;
} ;
body . querySelectorAll ( '[data-date]' ) . forEach ( cell => {
cell . addEventListener ( 'dragover' , ( e ) => {
if ( ! _dragUid ) return ;
e . preventDefault ( ) ;
// Only highlight the cell genuinely under the cursor — prevents two
// adjacent cells flashing as the cursor crosses a border.
const target = _cellAtPoint ( e . clientX , e . clientY ) ;
body . querySelectorAll ( '.cal-drag-over' ) . forEach ( c => {
if ( c !== target ) c . classList . remove ( 'cal-drag-over' ) ;
} ) ;
if ( target ) target . classList . add ( 'cal-drag-over' ) ;
} ) ;
cell . addEventListener ( 'dragleave' , ( e ) => {
// Only clear if the cursor really left this cell (dragleave fires when
// entering a child too — that's the flicker bug).
const target = _cellAtPoint ( e . clientX , e . clientY ) ;
if ( target !== cell ) cell . classList . remove ( 'cal-drag-over' ) ;
} ) ;
cell . addEventListener ( 'drop' , async ( e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
body . querySelectorAll ( '.cal-drag-over' ) . forEach ( c => c . classList . remove ( 'cal-drag-over' ) ) ;
if ( ! _dragUid ) return ;
// Drop target = whichever cell is actually under the cursor at release,
// not the bubbling target. Fixes "drops on wrong day" reports.
const target = _cellAtPoint ( e . clientX , e . clientY ) || cell ;
const nd = target . dataset . date ;
const ev = _events . find ( e => e . uid === _dragUid ) ;
if ( ! ev || ! nd ) return ;
const od = _localDateOf ( ev . dtstart ) ;
if ( od === nd ) return ;
const diff = Math . round ( ( new Date ( nd + 'T00:00:00' ) - new Date ( od + 'T00:00:00' ) ) / 86400000 ) ;
// Snapshot the original times for undo BEFORE we mutate.
const undoSnap = { uid : ev . uid , dtstart : ev . dtstart , dtend : ev . dtend } ;
_pushCalUndo ( { label : 'move' , run : ( ) => _updateEvent ( undoSnap . uid , { dtstart : undoSnap . dtstart , dtend : undoSnap . dtend || undefined } ) . then ( _render ) } ) ;
await _updateEvent ( ev . uid , { dtstart : _shiftDT ( ev . dtstart , diff ) , dtend : ev . dtend ? _shiftDT ( ev . dtend , diff ) : undefined } ) ;
_render ( ) ;
uiModule . showToast ? . ( 'Moved' , { duration : 4000 , action : 'Undo' , actionHint : 'Ctrl+Z' , onAction : _popAndRunCalUndo } ) ;
} ) ;
} ) ;
}
// ── Undo stack (calendar) ──
const _calUndoStack = [ ] ;
function _pushCalUndo ( entry ) {
_calUndoStack . push ( entry ) ;
if ( _calUndoStack . length > 20 ) _calUndoStack . shift ( ) ;
}
function _popAndRunCalUndo ( ) {
const entry = _calUndoStack . pop ( ) ;
if ( entry && typeof entry . run === 'function' ) {
try { entry . run ( ) ; } catch { }
}
}
// Ctrl/Cmd+Z anywhere inside the calendar modal undoes the last drag-move.
if ( typeof window !== 'undefined' && ! window . _calUndoBound ) {
window . _calUndoBound = true ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( ! ( e . ctrlKey || e . metaKey ) || e . key !== 'z' || e . shiftKey ) return ;
// Skip if the user's typing in a real field — let the browser's text undo run.
const t = e . target ;
if ( t && ( t . tagName === 'INPUT' || t . tagName === 'TEXTAREA' || t . isContentEditable ) ) return ;
const modal = document . getElementById ( 'calendar-modal' ) ;
if ( ! modal || modal . classList . contains ( 'hidden' ) || ! _calUndoStack . length ) return ;
e . preventDefault ( ) ;
_popAndRunCalUndo ( ) ;
} ) ;
}
// ── Calendar Settings ──
async function _showCalSettings ( ) {
const existing = document . getElementById ( 'cal-settings-panel' ) ;
if ( existing ) { existing . remove ( ) ; return ; }
const cals = _calendars ;
const COLORS = [ '#5b8abf' , '#4caf50' , '#ff9800' , '#e91e63' , '#9c27b0' , '#00bcd4' , '#795548' , '#607d8b' , '#f44336' , '#7c4dff' ] ;
const overlay = document . createElement ( 'div' ) ;
overlay . id = 'cal-settings-panel' ;
overlay . className = 'modal' ;
overlay . style . display = 'flex' ;
overlay . style . zIndex = '999' ;
overlay . innerHTML = `
< div class = "modal-content" style = "width:420px;max-width:92vw;" >
< div class = "modal-header" >
< h4 > Calendar Settings < / h 4 >
< button class = "close-btn" id = "cal-settings-close" > \u2716 < / b u t t o n >
< / d i v >
< div class = "modal-body" style = "padding:16px;display:flex;flex-direction:column;gap:16px;" >
< div >
< div style = "font-size:11px;opacity:0.5;margin-bottom:6px;" > Your calendars < / d i v >
< div id = "cal-settings-list" style = "display:flex;flex-direction:column;gap:4px;" >
$ { cals . map ( c => `
< 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);" >
< 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;" / >
< 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;" / >
< 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" / > < / s v g > < / b u t t o n >
< / d i v >
` ).join('')}
< / d i v >
< button class = "memory-toolbar-btn" id = "cal-settings-add" style = "margin-top:8px;" >
< 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" / > < / s v g >
New calendar
< / b u t t o n >
< / d i v >
< div style = "border-top:1px solid var(--border);padding-top:12px;" >
< div style = "font-size:11px;opacity:0.5;margin-bottom:6px;" > Import calendar < / d i v >
< div style = "display:flex;gap:8px;align-items:center;" >
< label class = "memory-toolbar-btn" style = "cursor:pointer;" >
< 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" / > < / s v g >
< span style = "position:relative;top:4px;" > Import . ics < / s p a n >
< input type = "file" accept = ".ics,.ical" id = "cal-import-file" style = "display:none;" / >
< / l a b e l >
< span id = "cal-import-status" style = "font-size:11px;opacity:0.6;" > < / s p a n >
< / d i v >
< 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 . < / d i v >
< / d i v >
< div style = "border-top:1px solid var(--border);padding-top:12px;" >
< div style = "font-size:11px;opacity:0.5;margin-bottom:6px;" > Export calendar < / d i v >
< div style = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;" >
$ { cals . map ( c => `
< button class = "memory-toolbar-btn cal-s-export-chip" data - id = "${_e(c.href)}" title = "Download ${_e(c.name)}.ics" style = "cursor:pointer;" >
< 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" / > < / s v g >
< span style = "position:relative;top:1px;" > $ { _e ( c . name ) } < / s p a n >
< / b u t t o n >
` ).join('')}
< / d i v >
< div style = "font-size:10px;opacity:0.4;margin-top:4px;" > Download a calendar as . ics for backup or to import into another app . < / d i v >
< / d i v >
< div style = "border-top:1px solid var(--border);padding-top:12px;" >
< div style = "font-size:11px;opacity:0.5;margin-bottom:6px;" > Sync < / d i v >
< div style = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" >
< button class = "memory-toolbar-btn" id = "cal-settings-sync-now" style = "cursor:pointer;" >
< 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" / > < / s v g >
< span style = "position:relative;top:1px;" > Sync now < / s p a n >
< / b u t t o n >
< span id = "cal-settings-sync-status" style = "font-size:11px;opacity:0.6;" > < / s p a n >
< / d i v >
< 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 > . < / d i v >
< / d i v >
< / d i v >
< / d i v >
` ;
document . body . appendChild ( overlay ) ;
const cleanup = ( ) => overlay . remove ( ) ;
overlay . querySelector ( '#cal-settings-close' ) . addEventListener ( 'click' , cleanup ) ;
overlay . addEventListener ( 'click' , ( e ) => { if ( e . target === overlay ) cleanup ( ) ; } ) ;
// Create a new (local) calendar. Defaults the name + next palette color, then
// reopens the panel so the user can rename it inline and pick a color.
overlay . querySelector ( '#cal-settings-add' ) ? . addEventListener ( 'click' , async ( e ) => {
const btn = e . currentTarget ;
btn . disabled = true ;
const color = COLORS [ _calendars . length % COLORS . length ] ;
try {
const r = await fetch ( ` ${ API _BASE } /api/calendar/calendars?name= ${ encodeURIComponent ( 'New calendar' ) } &color= ${ encodeURIComponent ( color ) } ` , { method : 'POST' , credentials : 'same-origin' } ) ;
const d = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok || ! d . ok ) throw new Error ( d . error || 'Failed to create calendar' ) ;
_calendars . push ( { name : d . name , href : d . id , color : d . color } ) ;
_allEvents = { } ; _fetchedRanges = [ ] ; localStorage . removeItem ( LS _KEY ) ;
_render ( ) ;
cleanup ( ) ;
_showCalSettings ( ) ;
// Focus the new row's name field so it's ready to rename.
setTimeout ( ( ) => {
const rows = document . querySelectorAll ( '#cal-settings-list .cal-settings-row' ) ;
const last = rows [ rows . length - 1 ] ;
const nm = last ? . querySelector ( '.cal-s-name' ) ;
if ( nm ) { nm . focus ( ) ; nm . select ( ) ; }
} , 30 ) ;
} catch ( err ) {
btn . disabled = false ;
if ( window . showError ) window . showError ( err . message || 'Failed to create calendar' ) ;
else console . error ( err ) ;
}
} ) ;
// Color + name changes
overlay . querySelectorAll ( '.cal-settings-row' ) . forEach ( row => {
const id = row . dataset . id ;
const colorInput = row . querySelector ( '.cal-s-color' ) ;
const nameInput = row . querySelector ( '.cal-s-name' ) ;
const delBtn = row . querySelector ( '.cal-s-del' ) ;
let saveTimer ;
const save = ( ) => {
clearTimeout ( saveTimer ) ;
saveTimer = setTimeout ( async ( ) => {
await fetch ( ` ${ API _BASE } /api/calendar/calendars/ ${ id } ?name= ${ encodeURIComponent ( nameInput . value ) } &color= ${ encodeURIComponent ( colorInput . value ) } ` , { method : 'PUT' } ) ;
if ( uiModule ? . showToast ) uiModule . showToast ( ` Saved “ ${ nameInput . value || 'calendar' } ” ` ) ;
// Update local calendar list
const c = _calendars . find ( c => c . href === id ) ;
if ( c ) { c . name = nameInput . value ; c . color = colorInput . value ; }
// Update colors on cached events
for ( const uid of Object . keys ( _allEvents ) ) {
if ( _allEvents [ uid ] . calendar _href === id ) {
_allEvents [ uid ] . color = colorInput . value ;
_allEvents [ uid ] . calendar = nameInput . value ;
}
}
localStorage . removeItem ( LS _KEY ) ;
_fetchedRanges = [ ] ;
_render ( ) ;
} , 300 ) ;
} ;
colorInput . addEventListener ( 'input' , save ) ;
nameInput . addEventListener ( 'change' , save ) ;
// Upgrade the native color box into the app's themed color picker.
try { attachColorPicker ( colorInput ) ; } catch ( _ ) { }
delBtn . addEventListener ( 'click' , async ( ) => {
const name = nameInput . value ;
if ( ! await window . styledConfirm ( ` Delete calendar " ${ name } " and all its events? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
await fetch ( ` ${ API _BASE } /api/calendar/calendars/ ${ id } ` , { method : 'DELETE' } ) ;
row . remove ( ) ;
_allEvents = { } ; _fetchedRanges = [ ] ; localStorage . removeItem ( LS _KEY ) ;
_calendars = _calendars . filter ( c => c . href !== id ) ;
_render ( ) ;
} ) ;
} ) ;
// ICS import
overlay . querySelector ( '#cal-import-file' ) . addEventListener ( 'change' , async ( e ) => {
const file = e . target . files [ 0 ] ;
if ( ! file ) return ;
const status = overlay . querySelector ( '#cal-import-status' ) ;
status . textContent = 'Importing...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'file' , file ) ;
const res = await fetch ( ` ${ API _BASE } /api/calendar/import ` , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
// Try JSON first; fall back to text so HTML auth-walls and bare
// 500s surface something the user can act on instead of the
// generic "Import failed".
let data = null , raw = '' ;
try { data = await res . clone ( ) . json ( ) ; } catch ( _ ) { raw = await res . text ( ) . catch ( ( ) => '' ) ; }
if ( res . ok && data && data . ok ) {
status . textContent = ` ${ data . imported } events imported to " ${ data . calendar } " ` + ( data . skipped ? ` ( ${ data . skipped } skipped) ` : '' ) ;
_allEvents = { } ; _fetchedRanges = [ ] ; localStorage . removeItem ( LS _KEY ) ;
await _fetchCalendars ( ) ;
_render ( ) ;
} else {
// FastAPI HTTPException → {detail}; some routes use {error}.
const reason = ( data && ( data . detail || data . error ) ) || raw . slice ( 0 , 200 ) || ` HTTP ${ res . status } ` ;
status . textContent = ` Import failed: ${ reason } ` ;
console . error ( 'Calendar import failed' , res . status , data || raw ) ;
}
} catch ( err ) {
status . textContent = ` Import failed: ${ err . message || err } ` ;
console . error ( 'Calendar import threw' , err ) ;
}
e . target . value = '' ;
} ) ;
// Export chips — one per calendar; downloads that calendar's .ics.
overlay . querySelectorAll ( '.cal-s-export-chip' ) . forEach ( chip => {
chip . addEventListener ( 'click' , ( ) => {
window . open ( ` ${ API _BASE } /api/calendar/export/ ${ chip . dataset . id } ` , '_blank' ) ;
} ) ;
} ) ;
// Sync now — fires the CalDAV pull synchronously so we can show the
// result inline, then refreshes the panel + calendar grid.
overlay . querySelector ( '#cal-settings-sync-now' ) ? . addEventListener ( 'click' , async ( e ) => {
const btn = e . currentTarget ;
const status = overlay . querySelector ( '#cal-settings-sync-status' ) ;
btn . disabled = true ;
status . textContent = 'Syncing…' ;
const data = await _syncCaldav ( true ) || { } ;
if ( data . errors && data . errors . length ) {
status . textContent = ` Sync failed: ${ data . errors [ 0 ] } ` ;
} else {
const parts = [ ] ;
if ( data . events ) parts . push ( ` ${ data . events } events ` ) ;
if ( data . deleted ) parts . push ( ` ${ data . deleted } removed ` ) ;
status . textContent = parts . length ? ` Synced — ${ parts . join ( ', ' ) } ` : 'Synced — no changes' ;
_allEvents = { } ; _fetchedRanges = [ ] ;
try { localStorage . removeItem ( LS _KEY ) ; } catch ( _ ) { }
await _fetchCalendars ( ) ;
_render ( ) ;
// Reopen the panel so the calendars list reflects any new ones.
const reopenWith = ! ! document . getElementById ( 'cal-settings-panel' ) ;
cleanup ( ) ;
if ( reopenWith ) _showCalSettings ( ) ;
}
btn . disabled = false ;
} ) ;
// Integrations link — close this overlay and open Settings → Integrations.
overlay . querySelector ( '#cal-settings-open-caldav' ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
cleanup ( ) ;
if ( window . settingsModule && typeof window . settingsModule . open === 'function' ) {
try { window . settingsModule . open ( 'integrations' ) ; return ; } catch ( _ ) { }
}
const modal = document . getElementById ( 'settings-modal' ) ;
if ( modal ) {
modal . classList . remove ( 'hidden' ) ;
const tabBtn = modal . querySelector ( '[data-settings-tab="integrations"]' ) ;
if ( tabBtn ) tabBtn . click ( ) ;
}
} ) ;
}
// ── Event Form ──
// Pull an explicit clock time out of a free-text title so it can overrule the
// time pickers on save (e.g. title "Standup 10am" wins over a 9pm picker).
// Returns {h, m} in 24h, or null when the title has no unambiguous time.
function _parseTitleTime ( text ) {
if ( ! text ) return null ;
// 12-hour with am/pm — "10am", "10:30 pm", "at 7 p.m."
let m = text . match ( /\b(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?\b/i ) ;
if ( m ) {
let h = parseInt ( m [ 1 ] , 10 ) ;
const mm = m [ 2 ] ? parseInt ( m [ 2 ] , 10 ) : 0 ;
if ( h < 1 || h > 12 || mm > 59 ) return null ;
const pm = m [ 3 ] . toLowerCase ( ) === 'p' ;
if ( pm && h !== 12 ) h += 12 ;
if ( ! pm && h === 12 ) h = 0 ;
return { h , m : mm } ;
}
// 24-hour HH:MM — "15:00", "at 9:30" (needs the colon to avoid matching
// bare numbers like "room 5" or years).
m = text . match ( /\b([01]?\d|2[0-3]):([0-5]\d)\b/ ) ;
if ( m ) return { h : parseInt ( m [ 1 ] , 10 ) , m : parseInt ( m [ 2 ] , 10 ) } ;
return null ;
}
function _showEventForm ( existing , defaultDate , defaultEndDate ) {
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const isEdit = ! ! existing ;
const ds = existing ? _localDateOf ( existing . dtstart ) : ( defaultDate || _today ( ) ) ;
const de = existing && existing . dtend ? _localDateOf ( existing . dtend ) : ( defaultEndDate || ds ) ;
const isMultiDay = ds !== de ;
const st = existing && ! existing . all _day ? _fmtTime ( existing . dtstart ) : '09:00' ;
const et = existing && ! existing . all _day && existing . dtend ? _fmtTime ( existing . dtend ) : '10:00' ;
// Default to all-day when dragging across multiple days
const ad = existing ? existing . all _day : ( defaultEndDate && defaultEndDate !== defaultDate ) ;
let calOpts = _calendars . filter ( c => ! _hiddenCals . has ( c . href ) ) . map ( c =>
` <option value=" ${ _e ( c . href ) } " ${ existing && existing . calendar _href === c . href ? 'selected' : '' } > ${ _e ( c . name ) } </option> `
) . join ( '' ) ;
// "Bespoke" event form: a big clock-face hero (time + date) and a single
// title input. Everything else (location, description, recurrence,
// reminder, color, calendar) is folded behind a click — focusing the
// title or clicking "Add details" reveals it. Empty drafts feel like a
// sticky-note; full-detail editing is one keystroke away.
const _hasDetails = ! ! ( existing && (
existing . location || existing . description || existing . rrule ||
( existing . color && existing . color . length ) ||
isMultiDay
) ) ;
const _expandedAtStart = isEdit && _hasDetails ;
body . innerHTML = ` <div class="cal-form cal-form-bespoke ${ _expandedAtStart ? ' is-expanded' : '' } ">
< button type = "button" class = "cal-form-mobile-cancel" id = "cal-form-mobile-cancel" title = "Cancel" aria - label = "Cancel event" >
< 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" / > < / s v g >
< / b u t t o n >
< div class = "cal-form-today" id = "cal-form-today" > Today is < span id = "cal-form-today-text" > $ { _clockDate ( _today ( ) ) } · $ { _nowClock ( ) } < / s p a n > < / d i v >
< div class = "cal-hero" >
< button type = "button" class = "cal-hero-time" id = "cal-hero-time" title = "Change time" >
< span class = "cal-hero-clock" id = "cal-hero-clock" > $ { _clockFace ( ad ? '' : st ) } < / s p a n >
< span class = "cal-hero-ampm" id = "cal-hero-ampm" > $ { _clockAmpm ( ad ? '' : st ) } < / s p a n >
< / b u t t o n >
< button type = "button" class = "cal-hero-date" id = "cal-hero-date" title = "Change date" > $ { _clockDate ( ds ) } < / b u t t o n >
< / d i v >
< div class = "cal-title-wrap" >
< input type = "text" id = "cal-f-sum" placeholder = " " value = "${_e(existing?.summary || '')}" class = "cal-input cal-hero-title" autocomplete = "off" / >
< 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" / > < / s v g > < / s p a n >
< / d i v >
< div class = "cal-form-details" id = "cal-form-details" aria - hidden = "${_expandedAtStart ? 'false' : 'true'}" >
< div class = "cal-form-row" >
< input type = "date" id = "cal-f-date" value = "${ds}" class = "cal-input" / >
< span style = "opacity:0.3" > to < / s p a n >
< input type = "date" id = "cal-f-date-end" value = "${de}" class = "cal-input" / >
< div class = "cal-allday-ctrl" >
< span class = "cal-allday-label" > All day < / s p a n >
< label class = "admin-switch cal-allday-switch" > < input type = "checkbox" id = "cal-f-allday" $ { ad ? 'checked' : '' } / > < span class = "admin-slider" > < / s p a n > < / l a b e l >
< / d i v >
< / d i v >
< div class = "cal-form-row" id = "cal-time-row" style = "${ad ? 'display:none' : ''}" >
< input type = "time" id = "cal-f-start" value = "${st}" class = "cal-input cal-input-time" / >
< span style = "opacity:0.3" > – < / s p a n >
< input type = "time" id = "cal-f-end" value = "${et}" class = "cal-input cal-input-time" / >
< / d i v >
< div class = "cal-loc-row" >
< input type = "text" id = "cal-f-loc" placeholder = "Location" value = "${_e(existing?.location || '')}" class = "cal-input" / >
< 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" >
< 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" / > < / s v g >
< / a >
< / d i v >
< select id = "cal-f-rrule" class = "cal-input" >
< option value = "" $ { ! existing ? . rrule ? 'selected' : '' } > Does not repeat < / o p t i o n >
< option value = "FREQ=DAILY" $ { existing ? . rrule === 'FREQ=DAILY' ? 'selected' : '' } > Daily < / o p t i o n >
< option value = "FREQ=WEEKLY" $ { existing ? . rrule === 'FREQ=WEEKLY' ? 'selected' : '' } > Weekly < / o p t i o n >
< option value = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" $ { existing ? . rrule === 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' ? 'selected' : '' } > Weekdays < / o p t i o n >
< option value = "FREQ=MONTHLY" $ { existing ? . rrule === 'FREQ=MONTHLY' ? 'selected' : '' } > Monthly < / o p t i o n >
< option value = "FREQ=YEARLY" $ { existing ? . rrule === 'FREQ=YEARLY' ? 'selected' : '' } > Yearly < / o p t i o n >
< / s e l e c t >
< textarea id = "cal-f-desc" placeholder = "Description" class = "cal-input" rows = "2" > $ { _e ( existing ? . description || '' ) } < / t e x t a r e a >
< div class = "cal-form-row" style = "align-items:center;gap:8px;" >
< label style = "font-size:11px;display:flex;align-items:center;gap:4px;" > < 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" / > < / s v g > < s p a n s t y l e = " o p a c i t y : 0 . 5 ; " > R e m i n d e r < / s p a n > < / l a b e l >
< select id = "cal-f-remind" class = "cal-input" style = "flex:1;" >
< option value = "" $ { isEdit ? 'selected' : '' } > No reminder < / o p t i o n >
< option value = "0" > At event time < / o p t i o n >
< option value = "5" > 5 minutes before < / o p t i o n >
< option value = "10" > 10 minutes before < / o p t i o n >
< option value = "15" $ { ! isEdit ? 'selected' : '' } > 15 minutes before < / o p t i o n >
< option value = "30" > 30 minutes before < / o p t i o n >
< option value = "60" > 1 hour before < / o p t i o n >
< option value = "120" > 2 hours before < / o p t i o n >
< option value = "1440" > 1 day before < / o p t i o n >
< option value = "custom" > Exact time ... < / o p t i o n >
< / s e l e c t >
< input type = "datetime-local" id = "cal-f-remind-custom" class = "cal-input" style = "flex:1;display:none;" / >
< / d i v >
< div class = "cal-form-row" style = "align-items:center;gap:8px;" >
< label style = "font-size:11px;opacity:0.5;" > Color < / l a b e l >
< div class = "note-color-picker" id = "cal-f-colors" >
$ { CAL _COLORS . map ( c => {
const cur = existing ? . color || '' ;
const isCustom = c . hex === 'custom' ;
const isActive = isCustom ? _isCalBgImage ( cur ) : ( cur === c . hex || ( ! cur && ! c . hex ) ) ;
let bg ;
if ( isCustom ) {
const url = _calBgImageUrl ( cur ) ;
bg = url ? ` center/cover no-repeat url(' ${ url } ') ` : _CAL _CUSTOM _GRADIENT ;
} else {
bg = c . hex || 'var(--border)' ;
}
return ` <span class="note-color-dot ${ isActive ? ' active' : '' } " data-color=" ${ c . hex } " style="background: ${ bg } " title=" ${ c . name } "></span> ` ;
} ) . join ( '' ) }
< / d i v >
< / d i v >
$ { _calendars . length > 1 ? ` <select id="cal-f-cal" class="cal-input cal-f-cal-select"> ${ calOpts } </select> ` : '' }
< / d i v >
< div class = "cal-form-actions" >
$ { 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> ` : '' }
< 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" / > < / s v g > C a n c e l < / b u t t o n >
< button id = "cal-f-save" class = "cal-btn cal-btn-primary" style = "display:inline-flex;align-items:center;gap:5px;" > $ { isEdit
? '<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'
: '<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' } < / b u t t o n >
< / d i v >
< / d i v > ` ;
document . getElementById ( 'cal-f-allday' ) ? . addEventListener ( 'change' , ( e ) => {
document . getElementById ( 'cal-time-row' ) . style . display = e . target . checked ? 'none' : '' ;
} ) ;
// Keep end date >= start date
document . getElementById ( 'cal-f-date' ) ? . addEventListener ( 'change' , ( ) => {
const s = document . getElementById ( 'cal-f-date' ) . value ;
const eEl = document . getElementById ( 'cal-f-date-end' ) ;
if ( eEl && eEl . value < s ) eEl . value = s ;
} ) ;
// Color dot picker — also live-tints the form card (border, focus
// rings, primary button) so the user sees the choice immediately.
const _formCard = document . querySelector ( '.cal-form-bespoke' ) ;
// Dismiss the keyboard by pressing Enter in a single-line text field — the
// ↵ glyph next to the title hints at this.
if ( _formCard ) {
_formCard . querySelectorAll ( 'input[type="text"]' ) . forEach ( inp => {
inp . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) { e . preventDefault ( ) ; inp . blur ( ) ; }
} ) ;
} ) ;
}
// Tint the calendar-picker select with the chosen calendar's colour so it's
// clear which calendar the event lands in.
const _calSel = document . getElementById ( 'cal-f-cal' ) ;
if ( _calSel ) {
const _tintCalSel = ( ) => {
const c = _calendars . find ( x => x . href === _calSel . value ) ;
const col = ( c && c . color && ! _isCalBgImage ( c . color ) ) ? c . color : 'var(--accent, var(--red))' ;
// Soft full-width background tint only — no side bar/border highlight.
_calSel . style . background = ` color-mix(in srgb, ${ col } 16%, var(--bg)) ` ;
} ;
_calSel . addEventListener ( 'change' , _tintCalSel ) ;
_tintCalSel ( ) ;
}
const _applyFormTint = ( hex ) => {
if ( ! _formCard ) return ;
if ( _isCalBgImage ( hex ) ) {
// Paint the form card with the uploaded image (mirrors how the notes
// form previews a custom-bg note), plus a translucent overlay so text
// stays readable. Chrome accent falls back to the theme accent.
const url = _calBgImageUrl ( hex ) ;
_formCard . style . setProperty ( '--ev-color' , 'var(--accent)' ) ;
_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 , "\\'" ) } ') ` ;
_formCard . style . backgroundSize = 'cover' ;
_formCard . style . backgroundPosition = 'center' ;
_formCard . classList . add ( 'cal-form-bg-image' ) ;
return ;
}
// Clear any prior custom-bg styling.
_formCard . classList . remove ( 'cal-form-bg-image' ) ;
_formCard . style . backgroundImage = '' ;
_formCard . style . backgroundSize = '' ;
_formCard . style . backgroundPosition = '' ;
if ( hex ) _formCard . style . setProperty ( '--ev-color' , hex ) ;
else _formCard . style . removeProperty ( '--ev-color' ) ;
} ;
document . querySelectorAll ( '#cal-f-colors .note-color-dot' ) . forEach ( dot => {
dot . addEventListener ( 'click' , async ( ) => {
// Custom dot: prompt for an image upload. Empty input → no-op.
if ( dot . dataset . color === 'custom' ) {
const url = await _pickCalBgImage ( ) ;
if ( ! url ) return ;
const sentinel = 'bg:' + url ;
dot . dataset . color = sentinel ;
dot . style . background = ` center/cover no-repeat url(' ${ url } ') ` ;
document . querySelectorAll ( '#cal-f-colors .note-color-dot' ) . forEach ( d => d . classList . remove ( 'active' ) ) ;
dot . classList . add ( 'active' ) ;
_applyFormTint ( sentinel ) ;
return ;
}
document . querySelectorAll ( '#cal-f-colors .note-color-dot' ) . forEach ( d => d . classList . remove ( 'active' ) ) ;
dot . classList . add ( 'active' ) ;
_applyFormTint ( dot . dataset . color || '' ) ;
} ) ;
} ) ;
// Initial tint for edit-an-existing-event so the card already reflects
// the saved color when the form opens.
_applyFormTint ( existing ? . color || '' ) ;
// When the user changes the start time, shift the end time by the same
// delta so the event keeps its original duration (or a 1-hour default if
// start == end). Skipped if the user has already nudged the end input
// since opening the form — we don't want to clobber a deliberate edit.
( function _wireStartShiftsEnd ( ) {
const startEl = document . getElementById ( 'cal-f-start' ) ;
const endEl = document . getElementById ( 'cal-f-end' ) ;
if ( ! startEl || ! endEl ) return ;
const _toMin = ( v ) => {
if ( ! v || ! /^\d{2}:\d{2}$/ . test ( v ) ) return null ;
const [ h , m ] = v . split ( ':' ) . map ( n => parseInt ( n , 10 ) ) ;
return h * 60 + m ;
} ;
const _toHHMM = ( mins ) => {
let m = ( ( mins % 1440 ) + 1440 ) % 1440 ;
const hh = String ( Math . floor ( m / 60 ) ) . padStart ( 2 , '0' ) ;
const mm = String ( m % 60 ) . padStart ( 2 , '0' ) ;
return ` ${ hh } : ${ mm } ` ;
} ;
let prevStartMin = _toMin ( startEl . value ) ;
endEl . addEventListener ( 'input' , ( ) => { endEl . dataset . userEdited = '1' ; } ) ;
startEl . addEventListener ( 'change' , ( ) => {
const newStartMin = _toMin ( startEl . value ) ;
const endMin = _toMin ( endEl . value ) ;
if ( newStartMin == null ) { prevStartMin = newStartMin ; return ; }
// Compute the duration before the change. Use the user's existing
// start→end gap, fallback to 1 hour.
let durationMin = 60 ;
if ( prevStartMin != null && endMin != null && endMin > prevStartMin ) {
durationMin = endMin - prevStartMin ;
} else if ( endMin != null && newStartMin != null && endMin > newStartMin && endEl . dataset . userEdited === '1' ) {
// User already set a custom end before changing start — leave it.
prevStartMin = newStartMin ;
return ;
}
endEl . value = _toHHMM ( newStartMin + durationMin ) ;
prevStartMin = newStartMin ;
} ) ;
} ) ( ) ;
// Custom reminder picker
document . getElementById ( 'cal-f-remind' ) ? . addEventListener ( 'change' , ( e ) => {
const customInput = document . getElementById ( 'cal-f-remind-custom' ) ;
if ( e . target . value === 'custom' ) {
customInput . style . display = '' ;
// Default to 1 hour before event
const dv = document . getElementById ( 'cal-f-date' ) ? . value || _today ( ) ;
const st = document . getElementById ( 'cal-f-start' ) ? . value || '09:00' ;
const eventDt = new Date ( ` ${ dv } T ${ st } :00 ` ) ;
eventDt . setHours ( eventDt . getHours ( ) - 1 ) ;
const pad = n => String ( n ) . padStart ( 2 , '0' ) ;
customInput . value = ` ${ eventDt . getFullYear ( ) } - ${ pad ( eventDt . getMonth ( ) + 1 ) } - ${ pad ( eventDt . getDate ( ) ) } T ${ pad ( eventDt . getHours ( ) ) } : ${ pad ( eventDt . getMinutes ( ) ) } ` ;
customInput . focus ( ) ;
} else {
customInput . style . display = 'none' ;
}
// Jingle the bell whenever a non-empty reminder is picked. CSS handles the
// animation; we just toggle the class so it re-fires on every change.
const _bell = document . querySelector ( '.cal-remind-bell' ) ;
if ( _bell && e . target . value ) {
_bell . classList . remove ( 'jingling' ) ;
void _bell . offsetWidth ;
_bell . classList . add ( 'jingling' ) ;
setTimeout ( ( ) => _bell . classList . remove ( 'jingling' ) , 700 ) ;
}
} ) ;
const _cancelEventForm = ( ) => _render ( ) ;
document . getElementById ( 'cal-f-cancel' ) ? . addEventListener ( 'click' , _cancelEventForm ) ;
document . getElementById ( 'cal-form-mobile-cancel' ) ? . addEventListener ( 'click' , _cancelEventForm ) ;
document . getElementById ( 'cal-f-save' ) ? . addEventListener ( 'click' , async ( ) => {
const summary = document . getElementById ( 'cal-f-sum' ) . value . trim ( ) ;
if ( ! summary ) { uiModule . showToast ( 'Title required' ) ; return ; }
const dv = document . getElementById ( 'cal-f-date' ) . value ;
const dvEnd = document . getElementById ( 'cal-f-date-end' ) . value || dv ;
const isAD = document . getElementById ( 'cal-f-allday' ) . checked ;
// Title overrules: if the title states a time, apply it to the start
// (keeping the current duration) so the picker can't silently disagree.
if ( ! isAD ) {
const tt = _parseTitleTime ( summary ) ;
const startEl = document . getElementById ( 'cal-f-start' ) ;
const endEl = document . getElementById ( 'cal-f-end' ) ;
const newStart = tt ? ` ${ String ( tt . h ) . padStart ( 2 , '0' ) } : ${ String ( tt . m ) . padStart ( 2 , '0' ) } ` : null ;
if ( newStart && startEl && startEl . value !== newStart ) {
const toMin = ( v ) => { const p = ( v || '' ) . split ( ':' ) ; return p . length === 2 ? ( + p [ 0 ] ) * 60 + ( + p [ 1 ] ) : null ; } ;
const s0 = toMin ( startEl . value ) , e0 = toMin ( endEl ? . value ) ;
const dur = ( s0 != null && e0 != null && e0 > s0 ) ? e0 - s0 : 60 ;
startEl . value = newStart ;
const endMin = ( tt . h * 60 + tt . m + dur ) % 1440 ;
if ( endEl ) endEl . value = ` ${ String ( Math . floor ( endMin / 60 ) ) . padStart ( 2 , '0' ) } : ${ String ( endMin % 60 ) . padStart ( 2 , '0' ) } ` ;
startEl . dispatchEvent ( new Event ( 'input' ) ) ;
}
}
const activeDot = document . querySelector ( '#cal-f-colors .note-color-dot.active' ) ;
const colorVal = activeDot ? . dataset . color || '' ;
// Append the user's current UTC offset so the backend stores events as
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets
// re-interpreted as local elsewhere — the timezone-misfire bug.
const _tz = _tzOffset ( ) ;
const payload = {
summary ,
dtstart : isAD ? dv : ` ${ dv } T ${ document . getElementById ( 'cal-f-start' ) . value } :00 ${ _tz } ` ,
dtend : isAD ? dvEnd : ` ${ dvEnd } T ${ document . getElementById ( 'cal-f-end' ) . value } :00 ${ _tz } ` ,
all _day : isAD ,
description : document . getElementById ( 'cal-f-desc' ) . value ,
location : document . getElementById ( 'cal-f-loc' ) . value ,
rrule : document . getElementById ( 'cal-f-rrule' ) . value || undefined ,
calendar _href : document . getElementById ( 'cal-f-cal' ) ? . value || ( _calendars [ 0 ] ? . href || '' ) ,
color : colorVal || undefined ,
} ;
try {
if ( isEdit ) await _updateEvent ( existing . uid , payload ) ;
else await _createEvent ( payload ) ;
// Create reminder if selected
const remindVal = document . getElementById ( 'cal-f-remind' ) ? . value ;
if ( remindVal ) {
let remindAt ;
if ( remindVal === 'custom' ) {
const customVal = document . getElementById ( 'cal-f-remind-custom' ) ? . value ;
remindAt = customVal ? new Date ( customVal ) : null ;
} else {
const eventStart = isAD ? new Date ( dv + 'T00:00:00' ) : new Date ( ` ${ dv } T ${ document . getElementById ( 'cal-f-start' ) . value } :00 ` ) ;
remindAt = new Date ( eventStart . getTime ( ) - parseInt ( remindVal ) * 60 * 1000 ) ;
}
if ( remindAt && remindAt > new Date ( ) ) {
await _createEventReminder ( { summary , dtstart : payload . dtstart , all _day : isAD , location : payload . location } , remindAt ) ;
}
}
_selectedDay = dv ; _render ( ) ;
} catch ( e ) { uiModule . showToast ( 'Failed to save' ) ; }
} ) ;
document . getElementById ( 'cal-f-del' ) ? . addEventListener ( 'click' , async ( ) => {
const name = existing && existing . summary ? ` " ${ existing . summary } " ` : 'this event' ;
const ok = await uiModule . styledConfirm ( ` Delete ${ name } ? ` , { confirmText : 'Delete' , danger : true } ) ;
if ( ! ok ) return ;
try { await _deleteEvent ( existing . uid ) ; _render ( ) ; }
catch ( e ) { uiModule . showToast ( 'Failed to delete' ) ; }
} ) ;
// ── Bespoke-form behavior ──────────────────────────────────────────
const formEl = body . querySelector ( '.cal-form' ) ;
const detailsEl = document . getElementById ( 'cal-form-details' ) ;
const titleInput = document . getElementById ( 'cal-f-sum' ) ;
const setExpanded = ( on ) => {
if ( ! formEl ) return ;
formEl . classList . toggle ( 'is-expanded' , on ) ;
if ( detailsEl ) detailsEl . setAttribute ( 'aria-hidden' , on ? 'false' : 'true' ) ;
} ;
// Focusing the title input unfolds the details once (new events). Edit
// mode opens already expanded when there's any detail content to see.
titleInput ? . addEventListener ( 'focus' , ( ) => setExpanded ( true ) , { once : true } ) ;
// Location → Apple Maps. The pin button next to the input is enabled
// only when there's a non-empty location, and its href tracks the live
// input value. Apple's universal URL opens the native Maps app on
// iOS/macOS and falls back to a web view on everything else.
const locInput = document . getElementById ( 'cal-f-loc' ) ;
const locMap = document . getElementById ( 'cal-f-loc-map' ) ;
const _syncLocMap = ( ) => {
if ( ! locMap ) return ;
const v = ( locInput ? . value || '' ) . trim ( ) ;
if ( ! v ) {
locMap . classList . add ( 'is-disabled' ) ;
locMap . removeAttribute ( 'href' ) ;
locMap . setAttribute ( 'tabindex' , '-1' ) ;
locMap . setAttribute ( 'aria-disabled' , 'true' ) ;
} else {
locMap . classList . remove ( 'is-disabled' ) ;
locMap . setAttribute ( 'href' , 'https://maps.apple.com/?q=' + encodeURIComponent ( v ) ) ;
locMap . setAttribute ( 'tabindex' , '0' ) ;
locMap . removeAttribute ( 'aria-disabled' ) ;
}
} ;
locInput ? . addEventListener ( 'input' , _syncLocMap ) ;
_syncLocMap ( ) ;
// Hero is clickable — clicking the time or date opens the matching
// native picker. Expands the details panel first so the input has been
// laid out (showPicker fails on display:none / 0-height inputs in some
// browsers).
const _openPicker = ( inputId , { uncheckAllDay = false } = { } ) => {
setExpanded ( true ) ;
const input = document . getElementById ( inputId ) ;
if ( ! input ) return ;
if ( uncheckAllDay ) {
const allday = document . getElementById ( 'cal-f-allday' ) ;
if ( allday && allday . checked ) {
allday . checked = false ;
document . getElementById ( 'cal-time-row' ) . style . display = '' ;
_syncHero ( ) ;
}
}
// Wait one frame for the reveal layout to settle.
requestAnimationFrame ( ( ) => {
input . focus ( ) ;
try { if ( typeof input . showPicker === 'function' ) input . showPicker ( ) ; } catch { }
} ) ;
} ;
document . getElementById ( 'cal-hero-time' ) ? . addEventListener ( 'click' , ( e ) => {
// Detect which segment of the visible clock was clicked (hh, mm, or
// somewhere else) so clicking the minutes digits puts the caret right
// on the minute field of the picker.
const seg = e . target ? . closest ( '[data-seg]' ) ? . dataset ? . seg ;
_openPicker ( 'cal-f-start' , { uncheckAllDay : true } ) ;
if ( seg === 'mm' ) {
// `<input type="time">` accepts setSelectionRange in Chromium for
// selecting the minute segment; Firefox/Safari are no-ops but the
// picker still opens, so nothing is lost.
requestAnimationFrame ( ( ) => {
const inp = document . getElementById ( 'cal-f-start' ) ;
if ( ! inp ) return ;
try { inp . setSelectionRange ( 3 , 5 ) ; } catch { }
} ) ;
}
} ) ;
document . getElementById ( 'cal-hero-date' ) ? . addEventListener ( 'click' , ( ) => {
_openPicker ( 'cal-f-date' ) ;
} ) ;
// Live hero clock — keep the big time/date in sync with the inputs the
// user can still tweak inside the details panel.
const _syncHero = ( ) => {
const allday = document . getElementById ( 'cal-f-allday' ) ? . checked ;
const startVal = document . getElementById ( 'cal-f-start' ) ? . value || '' ;
const dateVal = document . getElementById ( 'cal-f-date' ) ? . value || ds ;
const clockEl = document . getElementById ( 'cal-hero-clock' ) ;
const ampmEl = document . getElementById ( 'cal-hero-ampm' ) ;
const dateEl = document . getElementById ( 'cal-hero-date' ) ;
if ( clockEl ) clockEl . innerHTML = allday ? '<span class="cal-hero-clock-allday">All day</span>' : _clockFace ( startVal ) ;
if ( ampmEl ) ampmEl . textContent = allday ? '' : _clockAmpm ( startVal ) ;
if ( dateEl ) dateEl . textContent = _clockDate ( dateVal ) ;
} ;
document . getElementById ( 'cal-f-start' ) ? . addEventListener ( 'input' , _syncHero ) ;
document . getElementById ( 'cal-f-allday' ) ? . addEventListener ( 'change' , _syncHero ) ;
document . getElementById ( 'cal-f-date' ) ? . addEventListener ( 'change' , _syncHero ) ;
_syncHero ( ) ;
// New events: expand the details up front (don't rely on the title's focus
// event — programmatic .focus() is often a no-op on mobile, which would leave
// the form showing only the title + buttons), then focus the title.
if ( ! isEdit ) { setExpanded ( true ) ; titleInput ? . focus ( ) ; }
// Live "Today is …" tick. Updates every 30s; auto-stops the moment the
// header element disappears (any _render() call swaps #cal-body's HTML).
const _todayTextEl = document . getElementById ( 'cal-form-today-text' ) ;
if ( _todayTextEl ) {
const _tick = ( ) => {
const el = document . getElementById ( 'cal-form-today-text' ) ;
if ( ! el ) { clearInterval ( _todayInterval ) ; return ; }
el . textContent = ` ${ _clockDate ( _today ( ) ) } · ${ _nowClock ( ) } ` ;
} ;
const _todayInterval = setInterval ( _tick , 30000 ) ;
}
}
// ── Helpers ──
function _fmtDate ( s ) { return new Date ( s + 'T00:00:00' ) . toLocaleDateString ( undefined , { weekday : 'long' , month : 'long' , day : 'numeric' } ) ; }
// Hero clock helpers — used by the bespoke event form.
// _clockFace returns the colon-separated digits ("HH : MM"), _clockAmpm
// returns "AM"/"PM"/"" (empty for all-day), _clockDate is a long form
// "Sat · May 10, 2026". 24-h time stays without an AM/PM marker.
function _clockFace ( hhmm ) {
// Return the clock split into hh / separator / mm sub-spans so each
// segment is individually clickable. The wrapping #cal-hero-clock has
// its innerHTML re-set by _syncHero, so the spans round-trip cleanly.
if ( ! hhmm ) {
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>' ;
}
const [ h , m ] = hhmm . split ( ':' ) ;
const use12 = ( new Date ( ) ) . toLocaleString ( ) . toLowerCase ( ) . match ( /am|pm/ ) ;
let hh = parseInt ( h , 10 ) ;
if ( use12 ) { hh = ( ( hh + 11 ) % 12 ) + 1 ; }
const hhStr = String ( hh ) . padStart ( 2 , '0' ) ;
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> ` ;
}
function _clockAmpm ( hhmm ) {
if ( ! hhmm ) return '' ;
const use12 = ( new Date ( ) ) . toLocaleString ( ) . toLowerCase ( ) . match ( /am|pm/ ) ;
if ( ! use12 ) return '' ;
const h = parseInt ( hhmm . split ( ':' ) [ 0 ] , 10 ) ;
return h < 12 ? 'AM' : 'PM' ;
}
function _clockDate ( ds ) {
if ( ! ds ) return '' ;
return new Date ( ds + 'T00:00:00' ) . toLocaleDateString ( undefined , { weekday : 'short' , month : 'short' , day : 'numeric' , year : 'numeric' } ) ;
}
function _nowClock ( ) {
// Live wall-clock string for the "Today is …" header. Locale-aware so
// 24-h users don't see AM/PM.
return new Date ( ) . toLocaleTimeString ( undefined , { hour : 'numeric' , minute : '2-digit' } ) ;
}
function _fmtTime ( s ) {
if ( ! s || s . length < 16 ) return '' ;
2026-06-01 12:15:58 +10:00
// Tz-aware timestamps from CalDAV/import are stored as UTC instants and
// serialized with Z/offset. Display them in the browser's local timezone;
// legacy naive timestamps keep their written wall-clock time.
if ( /[Zz]$|[+\-]\d{2}:?\d{2}$/ . test ( s ) ) {
const d = new Date ( s ) ;
if ( ! isNaN ( d ) ) {
return ` ${ String ( d . getHours ( ) ) . padStart ( 2 , '0' ) } : ${ String ( d . getMinutes ( ) ) . padStart ( 2 , '0' ) } ` ;
}
}
2026-05-31 23:58:26 +09:00
return s . slice ( 11 , 16 ) ;
}
function _e ( s ) { return uiModule . esc ? uiModule . esc ( s || '' ) : ( s || '' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ; }
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
function _locHTML ( loc ) {
if ( ! loc ) return '' ;
const urlRe = /(https?:\/\/[^\s]+)/gi ;
if ( urlRe . test ( loc ) ) {
return loc . replace ( urlRe , ( url ) => {
const safe = _e ( url ) ;
return ` <a href=" ${ safe } " target="_blank" rel="noopener" onclick="event.stopPropagation();"> ${ safe } </a> ` ;
} ) . replace ( /\n/g , '<br>' ) ;
}
// No URL — link the whole thing to OpenStreetMap.
const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent ( loc ) ;
return ` <a href=" ${ mapUrl } " target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap"> ${ _e ( loc ) } </a> ` ;
}
// ── Open / Close ──
let _wheelDebounce = 0 ;
function _wheelNav ( e ) {
if ( ! _open ) return ;
// Don't intercept scroll inside the day-detail panel or any other inner scroll area
if ( e . target . closest ( '.cal-day-detail' ) || e . target . closest ( '.cal-form' ) ) return ;
const body = document . getElementById ( 'cal-body' ) ;
if ( ! body ) return ;
const now = Date . now ( ) ;
if ( now - _wheelDebounce < 300 ) { e . preventDefault ( ) ; return ; }
if ( Math . abs ( e . deltaY ) < 30 ) return ;
_wheelDebounce = now ;
e . preventDefault ( ) ;
if ( e . deltaY > 0 ) {
_slideDir = 1 ;
if ( _view === 'year' ) _currentDate = new Date ( _currentDate . getFullYear ( ) + 1 , 0 , 1 ) ;
else if ( _view === 'week' ) _currentDate . setDate ( _currentDate . getDate ( ) + 7 ) ;
else _currentDate = new Date ( _currentDate . getFullYear ( ) , _currentDate . getMonth ( ) + 1 , 1 ) ;
} else {
_slideDir = - 1 ;
if ( _view === 'year' ) _currentDate = new Date ( _currentDate . getFullYear ( ) - 1 , 0 , 1 ) ;
else if ( _view === 'week' ) _currentDate . setDate ( _currentDate . getDate ( ) - 7 ) ;
else _currentDate = new Date ( _currentDate . getFullYear ( ) , _currentDate . getMonth ( ) - 1 , 1 ) ;
}
_selectedDay = null ;
_render ( ) ;
}
function openCalendar ( ) {
if ( _open ) return ;
// If currently minimized — restore in place, preserve all state
if ( Modals . isMinimized ( 'calendar-modal' ) ) {
Modals . restore ( 'calendar-modal' ) ;
_open = true ;
return ;
}
_open = true ;
if ( _todayCount ( ) > 0 ) { _markBadgeSeen ( ) ; _updateBadge ( ) ; }
_collapseSidebar ( ) ;
const modal = _getModal ( ) ;
// Clean up any leftover state from a previous swipe-dismiss
modal . classList . remove ( 'hidden' , 'modal-minimized' ) ;
const _content = modal . querySelector ( '.modal-content' ) ;
if ( _content ) {
_content . classList . remove ( 'modal-closing' , 'sheet-ready' ) ;
_content . style . transform = '' ;
_content . style . transition = '' ;
_content . style . animation = '' ;
_content . style . opacity = '' ;
}
modal . style . display = 'flex' ;
Modals . register ( 'calendar-modal' , {
railBtnId : 'rail-calendar' ,
sidebarBtnId : 'tool-calendar-btn' ,
closeFn : ( ) => _doCloseCalendar ( ) ,
restoreFn : ( ) => { } ,
} ) ;
_currentDate = new Date ( ) ;
_selectedDay = _today ( ) ; // auto-show today's events on open
_view = 'month' ;
_scrollToTodayOnOpen = true ; // first render lands on today's row
_escHandler = ( e ) => {
if ( e . key === 'Escape' ) {
// Layer Esc: close the topmost calendar surface first, only fall through
// to closing the whole calendar when nothing else is on top.
const settings = document . getElementById ( 'cal-settings-panel' ) ;
if ( settings ) { settings . remove ( ) ; return ; }
if ( document . querySelector ( '.cal-form' ) ) { _render ( ) ; return ; }
closeCalendar ( ) ;
}
else if ( e . key === 'ArrowLeft' ) document . getElementById ( 'cal-prev' ) ? . click ( ) ;
else if ( e . key === 'ArrowRight' ) document . getElementById ( 'cal-next' ) ? . click ( ) ;
else if ( e . key === 't' || e . key === 'T' ) document . getElementById ( 'cal-today' ) ? . click ( ) ;
// Cmd/Ctrl+Z is handled by the module-level `_calUndoBound` listener,
// which consumes the shared `_calUndoStack`. Don't duplicate here.
} ;
document . addEventListener ( 'keydown' , _escHandler ) ;
const body = document . getElementById ( 'cal-body' ) ;
if ( body ) {
body . innerHTML = '<div class="cal-loading"></div>' ;
const wp = spinnerModule . createWhirlpool ( 28 ) ;
wp . element . style . margin = '40px auto' ;
body . querySelector ( '.cal-loading' ) . appendChild ( wp . element ) ;
body . addEventListener ( 'wheel' , _wheelNav , { passive : false } ) ;
}
_fetchCalendars ( ) . then ( ( ) => _render ( ) ) ;
}
// Open the calendar focused on a specific event (by uid) or date.
// Used by the chat anchor-link delegate so `[Wake up](#event-<uid>)`
// opens the calendar on that day with the event highlighted.
async function openCalendarTo ( target ) {
openCalendar ( ) ;
if ( ! target ) return ;
try {
await _fetchCalendars ( ) ;
// If target looks like an ISO date (YYYY-MM-DD...), go straight there.
let dt = null ;
const isoMatch = /^\d{4}-\d{2}-\d{2}/ . test ( String ( target ) ) ;
if ( isoMatch ) {
dt = new Date ( target ) ;
} else {
// Treat as an event uid — find it among loaded events.
const ev = ( _events || [ ] ) . find ( e => e . uid === target || ( e . uid || '' ) . startsWith ( target ) ) ;
if ( ev && ev . dtstart ) dt = new Date ( ev . dtstart ) ;
if ( ev ) _highlightEventUid = ev . uid ;
}
if ( dt && ! isNaN ( dt . getTime ( ) ) ) {
_currentDate = new Date ( dt ) ;
_selectedDay = new Date ( dt . getFullYear ( ) , dt . getMonth ( ) , dt . getDate ( ) ) ;
_view = 'month' ;
_render ( ) ;
}
} catch ( e ) { /* best-effort focus */ }
}
let _highlightEventUid = null ;
function _doCloseCalendar ( ) {
_open = false ;
_restoreSidebar ( ) ;
if ( _modal ) {
_modal . style . display = 'none' ;
_modal . classList . add ( 'hidden' ) ;
}
if ( _escHandler ) { document . removeEventListener ( 'keydown' , _escHandler ) ; _escHandler = null ; }
// Drop any pending undo — closures captured event uids/state that may
// no longer be valid by the time the user reopens. A reopened calendar
// starts with a clean slate.
_calUndoStack . length = 0 ;
}
function closeCalendar ( ) {
if ( ! _open && ! Modals . isMinimized ( 'calendar-modal' ) ) return ;
if ( Modals . isRegistered ( 'calendar-modal' ) ) {
Modals . close ( 'calendar-modal' ) ;
} else {
_doCloseCalendar ( ) ;
}
}
function isCalendarOpen ( ) {
// Treat minimized as "not open" so toggle handler will restore via Modals.toggle
if ( Modals . isMinimized ( 'calendar-modal' ) ) return false ;
return _open ;
}
// ── Persistent cache (localStorage) ──
const LS _KEY = 'odysseus-calendar-cache' ;
const LS _TTL = 10 * 60 * 1000 ; // 10 min
function _saveCache ( ) {
try {
const data = {
ts : Date . now ( ) ,
calendars : _calendars ,
events : Object . values ( _allEvents ) ,
ranges : _fetchedRanges ,
} ;
localStorage . setItem ( LS _KEY , JSON . stringify ( data ) ) ;
} catch ( e ) { }
}
function _loadCache ( ) {
try {
const raw = localStorage . getItem ( LS _KEY ) ;
if ( ! raw ) return false ;
const data = JSON . parse ( raw ) ;
if ( ! data . ts || Date . now ( ) - data . ts > LS _TTL ) return false ;
if ( data . calendars ) _calendars = data . calendars ;
if ( data . events ) data . events . forEach ( ev => { _allEvents [ ev . uid ] = ev ; } ) ;
// Don't restore _fetchedRanges — always re-fetch from API to pick up
// external changes (e.g. TimeTree sync adding events)
return true ;
} catch ( e ) { return false ; }
}
// Boot: load cache, refresh badge, prefetch current month
( async ( ) => {
_loadCache ( ) ;
_updateBadge ( ) ;
try {
await _fetchCalendars ( ) ;
_saveCache ( ) ;
const [ s , e ] = _monthRange ( new Date ( ) ) ;
await _fetchEvents ( s , e ) ;
_saveCache ( ) ;
_updateBadge ( ) ;
} catch ( e ) { }
} ) ( ) ;
// Live-refresh when the AI agent adds/edits/deletes events. chat.js dispatches
// `calendar-refresh` after a manage_calendar tool call, so a new event shows up
// without the user hard-refreshing. Drop the cache (so adds/edits/deletes all
// reflect), refetch the visible range, re-render if open, and update the badge.
window . addEventListener ( 'calendar-refresh' , ( ) => {
_allEvents = { } ;
_fetchedRanges = [ ] ;
const range = ( _view === 'year' )
? [ ` ${ _currentDate . getFullYear ( ) } -01-01 ` , ` ${ _currentDate . getFullYear ( ) + 1 } -01-01 ` ]
: ( _view === 'week' ) ? _weekRange ( _currentDate ) : _monthRange ( _currentDate ) ;
_fetchEvents ( range [ 0 ] , range [ 1 ] , /*force*/ true )
. then ( ( ) => { if ( _open ) _render ( ) ; _updateBadge ( ) ; } )
. catch ( ( ) => { } ) ;
} ) ;
// Calendar reminders are stored as Notes. The Notes reminder loop owns
// notification dispatch so calendar reminders do not fire twice.
const calendarModule = { openCalendar , closeCalendar , isCalendarOpen } ;
export { openCalendar , openCalendarTo , closeCalendar , isCalendarOpen } ;
export default calendarModule ;