@fraziym/axoniz 0.1.0

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