ui.js
1 import { 2 APP_ENTRY_KEY, 3 AUTH_STATE_KEY, 4 BUTTON_RESET_DELAY_MS, 5 COLOR_PALETTE, 6 DEV_HOSTS, 7 DEV_POLL_MS, 8 DEMO_MODE, 9 DOT_NAME_MAX_LENGTH, 10 MENU_SCRIM_HIDE_MS, 11 MOBILE_BREAKPOINT, 12 MOBILE_MONTH_BATCH_SIZE, 13 MOBILE_SCROLL_NEAR_TOP, 14 MODAL_ANIMATION_MS, 15 ONBOARDING_KEY, 16 PERIOD_SCROLL_THRESHOLD, 17 POPOVER_ANIMATION_MS, 18 SUGGESTED_DOT_TYPES, 19 STORAGE_KEY, 20 SUPPRESS_DAY_CLOSE_MS, 21 SUPPRESS_DAY_OPEN_MS, 22 VIEW_MODE_KEY, 23 YEAR_BATCH_SIZE 24 } from "./constants.js"; 25 import { 26 appShell, 27 colorModeDarkButton, 28 colorModeLightButton, 29 deleteModal, 30 deleteText, 31 dotTypeList, 32 hideSuggestionsInput, 33 marketingCalendar, 34 marketingHero, 35 marketingLogin, 36 marketingMonth, 37 marketingPage, 38 marketingYear, 39 menuScrim, 40 mobileMenuPortal, 41 monthGrid, 42 onboardingDotTypeList, 43 onboardingModal, 44 onboardingSuggestedDotList, 45 periodPickerLabel, 46 periodPickerMenu, 47 popover, 48 popoverItemTemplate, 49 popoverScrim, 50 showKeyboardHintsInput, 51 settingsModal, 52 suggestedDotContent, 53 suggestedDotList, 54 todayButton, 55 weekStartMondayInput, 56 yearGrid 57 } from "./dom.js"; 58 import { 59 buildMonthCells, 60 clamp, 61 formatISODate, 62 hash32, 63 isMobileView, 64 monthDiff, 65 startOfMonth, 66 weekdayShort 67 } from "./utils.js"; 68 import { 69 clearDotPosition, 70 createDemoState, 71 defaultState, 72 getDayDotIds, 73 getDayNote, 74 getDemoDotPosition, 75 getDotPosition, 76 normalizeImportedState, 77 normalizeDotTypeName, 78 requestRender, 79 saveAndRender, 80 saveDotPosition, 81 setDayNote, 82 setState, 83 state 84 } from "./state.js"; 85 import { showToast } from "./toast.js"; 86 87 let activePopover = null; 88 let activeNoteEdit = null; 89 let activeNoteEditMonthIso = null; 90 let pendingFocusDotId = null; 91 let pendingDeleteDotTypeId = null; 92 let pendingDeleteMode = "safe"; 93 let pendingDeleteDotTypeName = ""; 94 let loadedYearBatchCount = 1; 95 let loadedMobileMonthCount = 24; 96 let periodLoadInProgress = false; 97 let suppressDayOpenUntil = 0; 98 let monthScrollAttached = false; 99 let hasInitializedMobileMonthScroll = false; 100 let pendingMobileMonthAnchorIso = null; 101 let lastObservedMobileMonthIso = null; 102 let settingsModalHideTimer = null; 103 let popoverHideTimer = null; 104 let menuScrimHideTimer = null; 105 let hasEnteredApp = false; 106 let loginMode = false; 107 let updateAuthUIFn = () => {}; 108 let lastFocusTrigger = null; 109 110 // WeakMaps for private state on DOM elements (avoids underscore-prefixed properties). 111 const portalParents = new WeakMap(); 112 const hostRows = new WeakMap(); 113 const hexInputs = new WeakMap(); 114 const currentColors = new WeakMap(); 115 116 // Cached lookups rebuilt each render cycle. 117 let dotTypeMap = new Map(); 118 let dotTypeInUseSet = new Set(); 119 const DOT_SPACING_BY_MODE = { 120 year: 9, 121 month: 11 122 }; 123 const DOT_BOUNDS = { 124 minLeft: 6, 125 maxLeft: 94, 126 minTop: 12, 127 maxTop: 88 128 }; 129 const DOT_CANDIDATE_OFFSETS = [ 130 [0, 0], 131 [1, 0], 132 [-1, 0], 133 [0, 1], 134 [0, -1], 135 [1, 1], 136 [-1, 1], 137 [1, -1], 138 [-1, -1], 139 [2, 0], 140 [-2, 0], 141 [0, 2], 142 [0, -2], 143 [2, 1], 144 [-2, 1], 145 [2, -1], 146 [-2, -1], 147 [1, 2], 148 [-1, 2], 149 [1, -2], 150 [-1, -2] 151 ]; 152 const MARKETING_DOT_COLORS = { 153 "demo-exercise": "#FF0000", 154 "demo-sugar": "#FF7A00", 155 "demo-slept": "#FFC700", 156 "demo-reading": "#15C771", 157 "demo-cooking": "#2F8CFA", 158 "demo-social": "#B632CC", 159 "demo-movie": "#2F8CFA", 160 "demo-music": "#15C771" 161 }; 162 163 function getMarketingDotColor(dotType) { 164 return MARKETING_DOT_COLORS[dotType.id] || dotType.color; 165 } 166 167 function reduceMarketingDotDensity(demoState) { 168 const reducedDayDots = {}; 169 const keepWeekdaysByDot = { 170 "demo-exercise": new Set([1, 3, 5]), 171 "demo-slept": new Set([1, 2, 4, 6]), 172 "demo-reading": new Set([1, 4]), 173 "demo-cooking": new Set([0, 2, 6]), 174 "demo-social": new Set([2, 4, 6]), 175 "demo-sugar": new Set([6]), 176 "demo-movie": new Set([5]), 177 "demo-music": new Set([0, 6]) 178 }; 179 180 Object.entries(demoState.dayDots || {}).forEach(([iso, dotIds]) => { 181 const date = new Date(`${iso}T00:00:00`); 182 const weekday = date.getDay(); 183 const month = date.getMonth(); 184 const weekBucket = Math.floor((date.getDate() - 1) / 7); 185 186 const kept = dotIds.filter((dotId) => { 187 const weekdaySet = keepWeekdaysByDot[dotId]; 188 if (!weekdaySet || !weekdaySet.has(weekday)) return false; 189 190 // Keep seasonal signals visible while still reducing total dot count. 191 if (dotId === "demo-social") { 192 if (month >= 2 && month <= 3) return weekday !== 6 || weekBucket % 2 === 0; 193 if (month === 4) return false; 194 return weekBucket % 2 === 0; 195 } 196 if (dotId === "demo-sugar") { 197 if (month >= 10) return weekday === 0 || weekday === 6; 198 return weekday === 6 && weekBucket % 2 === 0; 199 } 200 if (dotId === "demo-music" && month === 4) { 201 return [1, 3, 5].includes(weekday); 202 } 203 if (dotId === "demo-slept" && month >= 10) { 204 return hash32(`${iso}|${dotId}|holiday-thin`) % 3 !== 0; 205 } 206 return true; 207 }); 208 209 if (kept.length > 0) reducedDayDots[iso] = kept; 210 }); 211 demoState.dayDots = reducedDayDots; 212 } 213 214 215 export function registerAuthUpdater(fn) { 216 updateAuthUIFn = fn; 217 } 218 219 export function getHasEnteredApp() { 220 return hasEnteredApp; 221 } 222 223 export function setHasEnteredApp(value) { 224 hasEnteredApp = value; 225 } 226 227 export function setLoginMode(value) { 228 loginMode = value; 229 } 230 231 export function showLogin() { 232 loginMode = true; 233 marketingHero?.classList.add("hidden"); 234 marketingLogin?.classList.remove("hidden"); 235 } 236 237 export function showMarketingHero() { 238 loginMode = false; 239 marketingLogin?.classList.add("hidden"); 240 marketingHero?.classList.remove("hidden"); 241 } 242 243 export function showMarketingPage() { 244 try { 245 localStorage.setItem(VIEW_MODE_KEY, "marketing"); 246 } catch { 247 // ignore 248 } 249 marketingPage?.classList.remove("hidden"); 250 appShell?.classList.add("hidden"); 251 } 252 253 export function resetToLoggedOut() { 254 hasEnteredApp = false; 255 loginMode = false; 256 try { 257 localStorage.removeItem(APP_ENTRY_KEY); 258 } catch { 259 // ignore 260 } 261 marketingLogin?.classList.add("hidden"); 262 marketingHero?.classList.remove("hidden"); 263 showMarketingPage(); 264 } 265 266 export function render() { 267 const active = document.activeElement; 268 const editingNote = active instanceof HTMLElement && active.classList.contains("note-editor"); 269 if (editingNote) return; 270 271 // Rebuild per-render caches for O(1) lookups. 272 dotTypeMap = new Map(state.dotTypes.map((d) => [d.id, d])); 273 dotTypeInUseSet = new Set(); 274 for (const ids of Object.values(state.dayDots)) { 275 for (const id of ids) dotTypeInUseSet.add(id); 276 } 277 278 applyTheme(); 279 renderPeriodPicker(); 280 renderDiaryGrid(); 281 updateTodayButtonVisibility(); 282 renderDotTypeList(); 283 if (weekStartMondayInput) weekStartMondayInput.checked = Boolean(state.weekStartsMonday); 284 if (hideSuggestionsInput) hideSuggestionsInput.checked = !state.hideSuggestions; 285 if (showKeyboardHintsInput) showKeyboardHintsInput.checked = Boolean(state.showKeyboardHints); 286 document.documentElement.classList.toggle("hide-keyboard-hints", !state.showKeyboardHints); 287 if (suggestedDotContent) { 288 suggestedDotContent.classList.toggle("hidden", Boolean(state.hideSuggestions)); 289 } 290 const darkModeEnabled = isDarkModeEnabled(); 291 colorModeLightButton?.classList.toggle("active", !darkModeEnabled); 292 colorModeDarkButton?.classList.toggle("active", darkModeEnabled); 293 renderSuggestedDotTypes(); 294 renderOnboardingLists(); 295 updateAuthUIFn(); 296 } 297 298 function isViewingTodayMonth() { 299 const selectedMonth = startOfMonth(new Date(state.monthCursor)); 300 const currentMonth = startOfMonth(new Date()); 301 return ( 302 selectedMonth.getFullYear() === currentMonth.getFullYear() && 303 selectedMonth.getMonth() === currentMonth.getMonth() 304 ); 305 } 306 307 function updateTodayButtonVisibility() { 308 if (!todayButton) return; 309 const isMobileMonthView = isMobileView() && !monthGrid.classList.contains("hidden"); 310 let shouldShow = false; 311 if (isMobileMonthView) { 312 const nearBottom = monthGrid.scrollTop + monthGrid.clientHeight >= monthGrid.scrollHeight - 28; 313 shouldShow = !nearBottom && !isViewingTodayMonth(); 314 } 315 todayButton.classList.toggle("hidden", !shouldShow); 316 } 317 318 export function renderPeriodPicker(preserveScroll = false, previousScrollTop = 0) { 319 if (!periodPickerMenu || !periodPickerLabel) return; 320 const currentYear = new Date().getFullYear(); 321 if (state.yearCursor > currentYear) { 322 state.yearCursor = currentYear; 323 } 324 periodPickerMenu.innerHTML = ""; 325 if (isMobileView()) { 326 const selectedMonthDate = new Date(state.monthCursor); 327 const currentMonthDate = startOfMonth(new Date()); 328 if (selectedMonthDate > currentMonthDate) { 329 state.monthCursor = currentMonthDate.toISOString(); 330 state.yearCursor = currentMonthDate.getFullYear(); 331 } 332 const normalizedSelectedMonthDate = new Date(state.monthCursor); 333 periodPickerLabel.textContent = normalizedSelectedMonthDate.toLocaleDateString(undefined, { 334 month: "short", 335 year: "numeric" 336 }); 337 const selectedMonthDiff = monthDiff(currentMonthDate, normalizedSelectedMonthDate); 338 if (selectedMonthDiff >= loadedMobileMonthCount) { 339 loadedMobileMonthCount = selectedMonthDiff + 1; 340 } 341 342 for (let i = 0; i < loadedMobileMonthCount; i += 1) { 343 const optionDate = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - i, 1); 344 const optionYear = optionDate.getFullYear(); 345 const optionMonth = optionDate.getMonth(); 346 347 const item = document.createElement("button"); 348 item.type = "button"; 349 item.className = "period-picker-item"; 350 if ( 351 optionYear === normalizedSelectedMonthDate.getFullYear() && 352 optionMonth === normalizedSelectedMonthDate.getMonth() 353 ) { 354 item.classList.add("active"); 355 } 356 item.textContent = optionDate.toLocaleDateString(undefined, { month: "short", year: "numeric" }); 357 item.addEventListener("click", () => { 358 state.monthCursor = startOfMonth(optionDate).toISOString(); 359 state.yearCursor = optionYear; 360 pendingMobileMonthAnchorIso = state.monthCursor; 361 closePeriodMenu(); 362 saveAndRender(); 363 }); 364 periodPickerMenu.appendChild(item); 365 } 366 367 if (preserveScroll) { 368 periodPickerMenu.scrollTop = previousScrollTop; 369 } 370 return; 371 } 372 373 periodPickerLabel.textContent = String(state.yearCursor); 374 const minLoadedYear = currentYear - loadedYearBatchCount * YEAR_BATCH_SIZE + 1; 375 if (state.yearCursor < minLoadedYear) { 376 loadedYearBatchCount = Math.ceil((currentYear - state.yearCursor + 1) / YEAR_BATCH_SIZE); 377 } 378 379 const oldestLoadedYear = currentYear - loadedYearBatchCount * YEAR_BATCH_SIZE + 1; 380 for (let year = currentYear; year >= oldestLoadedYear; year -= 1) { 381 const item = document.createElement("button"); 382 item.type = "button"; 383 item.className = "period-picker-item"; 384 if (year === state.yearCursor) item.classList.add("active"); 385 item.textContent = String(year); 386 item.addEventListener("click", () => { 387 state.yearCursor = year; 388 const monthDate = new Date(state.monthCursor); 389 monthDate.setFullYear(year); 390 state.monthCursor = startOfMonth(monthDate).toISOString(); 391 closePeriodMenu(); 392 saveAndRender(); 393 }); 394 periodPickerMenu.appendChild(item); 395 } 396 397 if (preserveScroll) { 398 periodPickerMenu.scrollTop = previousScrollTop; 399 } 400 } 401 402 export function shiftYearBy(delta) { 403 if (isMobileView()) return; 404 const amount = Number(delta) || 0; 405 if (!amount) return; 406 const currentYear = new Date().getFullYear(); 407 const nextYear = Math.min(currentYear, state.yearCursor + amount); 408 if (nextYear === state.yearCursor) return; 409 state.yearCursor = nextYear; 410 const monthDate = new Date(state.monthCursor); 411 monthDate.setFullYear(nextYear); 412 state.monthCursor = startOfMonth(monthDate).toISOString(); 413 closePeriodMenu(); 414 saveAndRender(); 415 } 416 417 export function renderDiaryGrid() { 418 if (isMobileView()) { 419 const wasHidden = monthGrid.classList.contains("hidden"); 420 yearGrid.classList.add("hidden"); 421 monthGrid.classList.remove("hidden"); 422 if (wasHidden) { 423 pendingMobileMonthAnchorIso = startOfMonth(new Date(state.monthCursor)).toISOString(); 424 } 425 renderMonthGrid(); 426 } else { 427 monthGrid.classList.add("hidden"); 428 yearGrid.classList.remove("hidden"); 429 renderYearGrid(); 430 } 431 } 432 433 export function renderYearGrid() { 434 const year = state.yearCursor; 435 const todayIso = formatISODate(new Date()); 436 yearGrid.innerHTML = ""; 437 438 for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) { 439 const column = document.createElement("section"); 440 column.className = "month-column"; 441 if (new Date(year, monthIndex + 1, 0).getDate() === 31) { 442 column.classList.add("month-31"); 443 } 444 445 const monthTitle = document.createElement("h3"); 446 monthTitle.className = "month-title"; 447 monthTitle.textContent = new Date(year, monthIndex, 1).toLocaleDateString(undefined, { month: "long" }); 448 column.appendChild(monthTitle); 449 450 const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); 451 for (let dayNum = 1; dayNum <= 31; dayNum += 1) { 452 if (dayNum > daysInMonth) { 453 const filler = document.createElement("div"); 454 filler.className = "year-day filler"; 455 column.appendChild(filler); 456 continue; 457 } 458 459 const date = new Date(year, monthIndex, dayNum); 460 const iso = formatISODate(date); 461 const isEditingThisDay = activeNoteEdit === iso; 462 const row = document.createElement(isEditingThisDay ? "div" : "button"); 463 if (!isEditingThisDay) row.type = "button"; 464 row.className = "year-day"; 465 if (iso === todayIso) row.classList.add("current-day"); 466 row.dataset.date = iso; 467 468 const label = document.createElement("span"); 469 label.className = "day-label"; 470 label.textContent = `${String(dayNum).padStart(2, "0")} ${weekdayShort(date)}`; 471 row.appendChild(label); 472 473 const dotLayer = document.createElement("div"); 474 dotLayer.className = "dot-layer"; 475 const dayDotIds = getDayDotIds(iso); 476 const resolvedPositions = resolveDotPositionsForDay({ 477 isoDate: iso, 478 dotIds: dayDotIds, 479 mode: "year", 480 getBasePosition: (dotId) => getDotPosition(iso, dotId, "year"), 481 isLocked: (dotId) => Boolean(state.dotPositions?.[iso]?.[dotId]) 482 }); 483 dayDotIds.forEach((dotId) => { 484 const dotType = dotTypeMap.get(dotId); 485 if (!dotType) return; 486 const sticker = document.createElement("span"); 487 sticker.className = "dot-sticker"; 488 sticker.style.background = dotType.color; 489 const pos = resolvedPositions.get(dotId) || getDotPosition(iso, dotId, "year"); 490 sticker.style.left = `${pos.left}%`; 491 sticker.style.top = `${pos.top}%`; 492 sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`; 493 sticker.title = `${dotType.name} (drag to move)`; 494 sticker.addEventListener("pointerdown", (event) => { 495 startDotDrag(event, { isoDate: iso, dotId, sticker, mode: "year" }); 496 }); 497 dotLayer.appendChild(sticker); 498 }); 499 row.appendChild(dotLayer); 500 501 const note = getDayNote(iso); 502 if (activeNoteEdit === iso) { 503 row.appendChild(buildNoteEditor(iso, "day-note", null)); 504 } else if (note) { 505 const noteNode = document.createElement("span"); 506 noteNode.className = "day-note"; 507 noteNode.textContent = note; 508 row.appendChild(noteNode); 509 } 510 511 if (!isEditingThisDay) { 512 row.addEventListener("click", (event) => { 513 if (Date.now() < suppressDayOpenUntil) return; 514 if (activePopover && activePopover.isoDate !== iso) { 515 closePopover(); 516 return; 517 } 518 openPopover(iso, event.clientX, event.clientY, null); 519 }); 520 } 521 column.appendChild(row); 522 } 523 524 yearGrid.appendChild(column); 525 } 526 } 527 528 export function renderMonthGrid() { 529 const selectedMonthDate = startOfMonth(new Date(state.monthCursor)); 530 const currentMonthDate = startOfMonth(new Date()); 531 const todayIso = formatISODate(new Date()); 532 const previousScrollTop = monthGrid.scrollTop; 533 monthGrid.innerHTML = ""; 534 monthGrid.classList.add("month-scroll-list"); 535 536 const buildMonthCell = (day, monthIso) => { 537 const isEditingThisDay = 538 activeNoteEdit === day.iso && 539 (!activeNoteEditMonthIso || activeNoteEditMonthIso === monthIso); 540 const cell = document.createElement(isEditingThisDay ? "div" : "button"); 541 if (!isEditingThisDay) cell.type = "button"; 542 cell.className = "month-day"; 543 if (day.iso === todayIso) cell.classList.add("current-day"); 544 if (!day.inCurrentMonth) cell.classList.add("muted-day"); 545 cell.dataset.date = day.iso; 546 547 const dayLabel = document.createElement("div"); 548 dayLabel.className = "month-day-label"; 549 dayLabel.textContent = `${String(day.date.getDate()).padStart(2, "0")} ${weekdayShort(day.date)}`; 550 cell.appendChild(dayLabel); 551 552 const dotLayer = document.createElement("div"); 553 dotLayer.className = "dot-layer"; 554 const dayDotIds = getDayDotIds(day.iso); 555 const resolvedPositions = resolveDotPositionsForDay({ 556 isoDate: day.iso, 557 dotIds: dayDotIds, 558 mode: "month", 559 getBasePosition: (dotId) => getDotPosition(day.iso, dotId, "month"), 560 isLocked: (dotId) => Boolean(state.dotPositions?.[day.iso]?.[dotId]) 561 }); 562 dayDotIds.forEach((dotId) => { 563 const dotType = dotTypeMap.get(dotId); 564 if (!dotType) return; 565 const sticker = document.createElement("span"); 566 sticker.className = "dot-sticker"; 567 sticker.style.background = dotType.color; 568 const pos = resolvedPositions.get(dotId) || getDotPosition(day.iso, dotId, "month"); 569 sticker.style.left = `${pos.left}%`; 570 sticker.style.top = `${pos.top}%`; 571 sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`; 572 sticker.title = `${dotType.name} (drag to move)`; 573 sticker.addEventListener("pointerdown", (event) => { 574 startDotDrag(event, { isoDate: day.iso, dotId, sticker, mode: "month" }); 575 }); 576 dotLayer.appendChild(sticker); 577 }); 578 cell.appendChild(dotLayer); 579 580 const note = getDayNote(day.iso); 581 if (isEditingThisDay) { 582 cell.appendChild(buildNoteEditor(day.iso, "month-note", monthIso)); 583 } else if (note) { 584 const noteNode = document.createElement("span"); 585 noteNode.className = "month-note"; 586 noteNode.textContent = note; 587 cell.appendChild(noteNode); 588 } 589 590 if (!isEditingThisDay) { 591 cell.addEventListener("click", (event) => { 592 if (Date.now() < suppressDayOpenUntil) return; 593 if (activePopover && activePopover.isoDate !== day.iso) { 594 closePopover(); 595 return; 596 } 597 openPopover(day.iso, event.clientX, event.clientY, monthIso); 598 }); 599 } 600 return cell; 601 }; 602 603 const monthsToRender = Math.max(loadedMobileMonthCount, monthDiff(currentMonthDate, selectedMonthDate) + 1); 604 605 const monthSections = []; 606 for (let i = 0; i < monthsToRender; i += 1) { 607 const monthDate = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - i, 1); 608 const monthIso = startOfMonth(monthDate).toISOString(); 609 610 const section = document.createElement("section"); 611 section.className = "month-scroll-section"; 612 section.dataset.monthIso = monthIso; 613 614 const title = document.createElement("h3"); 615 title.className = "month-scroll-title"; 616 title.textContent = monthDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }); 617 section.appendChild(title); 618 619 const daysWrap = document.createElement("div"); 620 daysWrap.className = "month-scroll-days"; 621 const days = buildMonthCells(monthDate, state.weekStartsMonday); 622 days.forEach((day) => { 623 daysWrap.appendChild(buildMonthCell(day, monthIso)); 624 }); 625 section.appendChild(daysWrap); 626 monthSections.push(section); 627 } 628 629 for (let i = monthSections.length - 1; i >= 0; i -= 1) { 630 monthGrid.appendChild(monthSections[i]); 631 } 632 633 if (!monthGrid.dataset.scrollListenerAttached) { 634 monthGrid.dataset.scrollListenerAttached = "1"; 635 monthGrid.addEventListener( 636 "scroll", 637 () => { 638 if (!isMobileView()) return; 639 const sections = monthGrid.querySelectorAll(".month-scroll-section"); 640 const containerRect = monthGrid.getBoundingClientRect(); 641 const containerCenter = containerRect.top + containerRect.height / 2; 642 let nearest = null; 643 let nearestDistance = Number.POSITIVE_INFINITY; 644 sections.forEach((section) => { 645 const rect = section.getBoundingClientRect(); 646 const sectionCenter = rect.top + rect.height / 2; 647 const distance = Math.abs(sectionCenter - containerCenter); 648 if (distance < nearestDistance) { 649 nearestDistance = distance; 650 nearest = section; 651 } 652 }); 653 if (!nearest?.dataset.monthIso) return; 654 const nearestMonthIso = nearest.dataset.monthIso; 655 if (nearestMonthIso !== lastObservedMobileMonthIso) { 656 lastObservedMobileMonthIso = nearestMonthIso; 657 const monthDate = new Date(nearestMonthIso); 658 state.monthCursor = startOfMonth(monthDate).toISOString(); 659 state.yearCursor = monthDate.getFullYear(); 660 periodPickerLabel.textContent = monthDate.toLocaleDateString(undefined, { 661 month: "short", 662 year: "numeric" 663 }); 664 } 665 updateTodayButtonVisibility(); 666 667 const nearTop = monthGrid.scrollTop <= MOBILE_SCROLL_NEAR_TOP; 668 if (nearTop && !periodLoadInProgress) { 669 periodLoadInProgress = true; 670 const previousHeight = monthGrid.scrollHeight; 671 const previousTop = monthGrid.scrollTop; 672 loadedMobileMonthCount += MOBILE_MONTH_BATCH_SIZE; 673 requestRender(() => { 674 const addedHeight = monthGrid.scrollHeight - previousHeight; 675 monthGrid.scrollTop = previousTop + Math.max(0, addedHeight); 676 periodLoadInProgress = false; 677 }); 678 } 679 }, 680 { passive: true } 681 ); 682 } 683 684 const selectedMonthIso = selectedMonthDate.toISOString(); 685 const initialAnchorIso = !hasInitializedMobileMonthScroll ? selectedMonthIso : null; 686 const stateChangeAnchorIso = 687 hasInitializedMobileMonthScroll && selectedMonthIso !== lastObservedMobileMonthIso ? selectedMonthIso : null; 688 const targetAnchorIso = pendingMobileMonthAnchorIso || stateChangeAnchorIso || initialAnchorIso; 689 lastObservedMobileMonthIso = targetAnchorIso || selectedMonthIso; 690 if (targetAnchorIso) { 691 requestAnimationFrame(() => { 692 const target = monthGrid.querySelector(`[data-month-iso="${targetAnchorIso}"]`); 693 if (target) { 694 target.scrollIntoView({ block: "start" }); 695 } else { 696 monthGrid.scrollTop = previousScrollTop; 697 } 698 hasInitializedMobileMonthScroll = true; 699 pendingMobileMonthAnchorIso = null; 700 updateTodayButtonVisibility(); 701 }); 702 } else { 703 monthGrid.scrollTop = previousScrollTop; 704 updateTodayButtonVisibility(); 705 } 706 } 707 708 export function scrollToToday() { 709 const today = new Date(); 710 const todayMonth = startOfMonth(today); 711 712 state.monthCursor = todayMonth.toISOString(); 713 state.yearCursor = todayMonth.getFullYear(); 714 closePeriodMenu(); 715 periodPickerLabel.textContent = todayMonth.toLocaleDateString(undefined, { 716 month: "short", 717 year: "numeric" 718 }); 719 720 if (isMobileView() && monthGrid?.classList.contains("month-scroll-list")) { 721 pendingMobileMonthAnchorIso = null; 722 monthGrid.scrollTo({ 723 top: Math.max(0, monthGrid.scrollHeight - monthGrid.clientHeight), 724 behavior: "smooth" 725 }); 726 requestAnimationFrame(() => updateTodayButtonVisibility()); 727 return; 728 } 729 730 saveAndRender(); 731 } 732 733 function resolveDotPositionsForDay({ isoDate, dotIds, mode, getBasePosition, isLocked }) { 734 const resolved = new Map(); 735 if (!Array.isArray(dotIds) || dotIds.length <= 1) return resolved; 736 737 const spacing = DOT_SPACING_BY_MODE[mode] || DOT_SPACING_BY_MODE.year; 738 const clamped = (left, top) => ({ 739 left: clamp(left, DOT_BOUNDS.minLeft, DOT_BOUNDS.maxLeft), 740 top: clamp(top, DOT_BOUNDS.minTop, DOT_BOUNDS.maxTop) 741 }); 742 const distanceToClosest = (candidate) => { 743 const points = [...resolved.values()]; 744 if (points.length === 0) return Number.POSITIVE_INFINITY; 745 return points.reduce((min, placed) => { 746 const dx = placed.left - candidate.left; 747 const dy = placed.top - candidate.top; 748 return Math.min(min, Math.hypot(dx, dy)); 749 }, Number.POSITIVE_INFINITY); 750 }; 751 752 const makeCandidates = (base) => 753 DOT_CANDIDATE_OFFSETS.map(([ox, oy]) => { 754 const point = clamped(base.left + ox * spacing, base.top + oy * spacing); 755 return { ...base, left: point.left, top: point.top }; 756 }); 757 758 dotIds.forEach((dotId) => { 759 if (!isLocked(dotId)) return; 760 resolved.set(dotId, getBasePosition(dotId)); 761 }); 762 763 dotIds.forEach((dotId) => { 764 if (resolved.has(dotId)) return; 765 const base = getBasePosition(dotId); 766 const candidates = makeCandidates(base); 767 let best = candidates[0]; 768 let bestClearance = distanceToClosest(best); 769 770 candidates.forEach((candidate) => { 771 const clearance = distanceToClosest(candidate); 772 if (clearance >= spacing && bestClearance < spacing) { 773 best = candidate; 774 bestClearance = clearance; 775 return; 776 } 777 if ((clearance >= spacing && bestClearance >= spacing) || (clearance < spacing && bestClearance < spacing)) { 778 const bestDist = Math.hypot(best.left - base.left, best.top - base.top); 779 const candidateDist = Math.hypot(candidate.left - base.left, candidate.top - base.top); 780 if ( 781 clearance > bestClearance + 0.01 || 782 (Math.abs(clearance - bestClearance) < 0.01 && candidateDist < bestDist) 783 ) { 784 best = candidate; 785 bestClearance = clearance; 786 } 787 } 788 }); 789 790 resolved.set(dotId, best); 791 }); 792 793 return resolved; 794 } 795 796 export function renderMarketingCalendar() { 797 if (!marketingCalendar || !marketingYear || !marketingMonth) return; 798 // Skip heavy render if the user is already signed in and won't see the marketing page. 799 if (hasEnteredApp && marketingPage?.classList.contains("hidden")) return; 800 const demoState = createDemoState(); 801 reduceMarketingDotDensity(demoState); 802 const year = demoState.yearCursor; 803 const todayIso = formatISODate(new Date()); 804 marketingYear.innerHTML = ""; 805 marketingMonth.innerHTML = ""; 806 807 for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) { 808 const column = document.createElement("section"); 809 column.className = "month-column"; 810 if (new Date(year, monthIndex + 1, 0).getDate() === 31) { 811 column.classList.add("month-31"); 812 } 813 814 const monthTitle = document.createElement("h3"); 815 monthTitle.className = "month-title"; 816 monthTitle.textContent = new Date(year, monthIndex, 1).toLocaleDateString(undefined, { month: "long" }); 817 column.appendChild(monthTitle); 818 819 const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); 820 for (let dayNum = 1; dayNum <= 31; dayNum += 1) { 821 if (dayNum > daysInMonth) { 822 const filler = document.createElement("div"); 823 filler.className = "year-day filler"; 824 column.appendChild(filler); 825 continue; 826 } 827 828 const date = new Date(year, monthIndex, dayNum); 829 const iso = formatISODate(date); 830 const row = document.createElement("div"); 831 row.className = "year-day"; 832 if (iso === todayIso) row.classList.add("current-day"); 833 834 const label = document.createElement("span"); 835 label.className = "day-label"; 836 label.textContent = `${String(dayNum).padStart(2, "0")} ${weekdayShort(date)}`; 837 row.appendChild(label); 838 839 const dotLayer = document.createElement("div"); 840 dotLayer.className = "dot-layer"; 841 (demoState.dayDots[iso] || []).forEach((dotId) => { 842 const dotType = demoState.dotTypes.find((t) => t.id === dotId); 843 if (!dotType) return; 844 const sticker = document.createElement("span"); 845 sticker.className = "dot-sticker"; 846 sticker.style.background = getMarketingDotColor(dotType); 847 const pos = getDemoDotPosition(demoState, iso, dotId); 848 sticker.style.left = `${pos.left}%`; 849 sticker.style.top = `${pos.top}%`; 850 sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`; 851 dotLayer.appendChild(sticker); 852 }); 853 row.appendChild(dotLayer); 854 855 const note = demoState.dayNotes[iso]; 856 if (note) { 857 const noteNode = document.createElement("span"); 858 noteNode.className = "day-note"; 859 noteNode.textContent = note; 860 row.appendChild(noteNode); 861 } 862 863 column.appendChild(row); 864 } 865 866 marketingYear.appendChild(column); 867 } 868 869 renderMarketingMonth(demoState); 870 } 871 872 export function renderMarketingMonth(demoState) { 873 const monthDate = startOfMonth(new Date()); 874 const days = buildMonthCells(monthDate, demoState.weekStartsMonday); 875 const todayIso = formatISODate(new Date()); 876 marketingMonth.innerHTML = ""; 877 878 for (const day of days) { 879 const cell = document.createElement("div"); 880 cell.className = "month-day"; 881 if (day.iso === todayIso) cell.classList.add("current-day"); 882 if (!day.inCurrentMonth) cell.classList.add("muted-day"); 883 884 const dayLabel = document.createElement("div"); 885 dayLabel.className = "month-day-label"; 886 dayLabel.textContent = `${String(day.date.getDate()).padStart(2, "0")} ${weekdayShort(day.date)}`; 887 cell.appendChild(dayLabel); 888 889 const dotLayer = document.createElement("div"); 890 dotLayer.className = "dot-layer"; 891 (demoState.dayDots[day.iso] || []).forEach((dotId) => { 892 const dotType = demoState.dotTypes.find((t) => t.id === dotId); 893 if (!dotType) return; 894 const sticker = document.createElement("span"); 895 sticker.className = "dot-sticker"; 896 sticker.style.background = getMarketingDotColor(dotType); 897 const pos = getDemoDotPosition(demoState, day.iso, dotId); 898 sticker.style.left = `${pos.left}%`; 899 sticker.style.top = `${pos.top}%`; 900 sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`; 901 dotLayer.appendChild(sticker); 902 }); 903 cell.appendChild(dotLayer); 904 905 const note = demoState.dayNotes[day.iso]; 906 if (note) { 907 const noteNode = document.createElement("span"); 908 noteNode.className = "month-note"; 909 noteNode.textContent = note; 910 cell.appendChild(noteNode); 911 } 912 913 marketingMonth.appendChild(cell); 914 } 915 } 916 917 export function renderDotTypeList(targetList = dotTypeList) { 918 if (!targetList) return; 919 targetList.innerHTML = ""; 920 921 if (state.dotTypes.length === 0) { 922 const empty = document.createElement("li"); 923 empty.className = "empty-state"; 924 empty.innerHTML = "You don’t have any dot types yet"; 925 targetList.appendChild(empty); 926 } 927 928 state.dotTypes.forEach((dotType) => { 929 const item = document.createElement("li"); 930 item.className = "dot-type-row"; 931 932 const swatch = document.createElement("span"); 933 swatch.className = "swatch"; 934 swatch.style.background = dotType.color; 935 936 const inputWrap = document.createElement("div"); 937 inputWrap.className = "dot-input-wrap"; 938 939 const nameInput = document.createElement("input"); 940 nameInput.type = "text"; 941 nameInput.value = dotType.name; 942 nameInput.maxLength = DOT_NAME_MAX_LENGTH; 943 nameInput.setAttribute("aria-label", "Dot meaning"); 944 nameInput.className = "dot-name-input"; 945 syncDotTypeInputSize(nameInput); 946 nameInput.addEventListener("input", () => { 947 syncDotTypeInputSize(nameInput); 948 }); 949 nameInput.addEventListener("focus", () => { 950 nameInput.select(); 951 }); 952 nameInput.addEventListener("click", () => { 953 nameInput.select(); 954 }); 955 nameInput.addEventListener("change", () => { 956 const nextName = normalizeDotTypeName(nameInput.value) || dotType.name; 957 const changed = nextName !== dotType.name; 958 dotType.name = nextName; 959 nameInput.value = nextName; 960 syncDotTypeInputSize(nameInput); 961 saveAndRender(); 962 if (changed) { 963 showToast(`Renamed dot to "${nextName}".`); 964 } 965 }); 966 if (pendingFocusDotId === dotType.id) { 967 requestAnimationFrame(() => { 968 nameInput.focus(); 969 nameInput.select(); 970 }); 971 pendingFocusDotId = null; 972 } 973 974 const colorPicker = buildColorPicker(dotType, swatch); 975 swatch.addEventListener("click", (event) => { 976 event.stopPropagation(); 977 openColorPicker(colorPicker); 978 }); 979 980 const inUse = isDotTypeInUse(dotType.id); 981 982 const actions = document.createElement("div"); 983 actions.className = "dot-actions"; 984 985 const toggle = document.createElement("button"); 986 toggle.type = "button"; 987 toggle.className = "dot-actions-toggle"; 988 toggle.innerHTML = ` 989 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> 990 <path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.5C5.82843 8.5 6.5 9.17157 6.5 10C6.5 10.8284 5.82843 11.5 5 11.5C4.17157 11.5 3.5 10.8284 3.5 10C3.5 9.17157 4.17157 8.5 5 8.5Z" fill="currentColor"/> 991 <path fill-rule="evenodd" clip-rule="evenodd" d="M10 8.5C10.8284 8.5 11.5 9.17157 11.5 10C11.5 10.8284 10.8284 11.5 10 11.5C9.17157 11.5 8.5 10.8284 8.5 10C8.5 9.17157 9.17157 8.5 10 8.5Z" fill="currentColor"/> 992 <path fill-rule="evenodd" clip-rule="evenodd" d="M15 8.5C15.8284 8.5 16.5 9.17157 16.5 10C16.5 10.8284 15.8284 11.5 15 11.5C14.1716 11.5 13.5 10.8284 13.5 10C13.5 9.17157 14.1716 8.5 15 8.5Z" fill="currentColor"/> 993 </svg> 994 `; 995 toggle.setAttribute("aria-label", "More actions"); 996 997 const menu = document.createElement("div"); 998 menu.className = "dot-actions-menu hidden"; 999 menu.dataset.portal = "dot-actions"; 1000 1001 const renameItem = document.createElement("button"); 1002 renameItem.type = "button"; 1003 renameItem.className = "dot-actions-item"; 1004 renameItem.textContent = "Rename"; 1005 renameItem.addEventListener("click", () => { 1006 closeDotMenus(); 1007 nameInput.focus(); 1008 nameInput.select(); 1009 }); 1010 1011 const deleteItem = document.createElement("button"); 1012 deleteItem.type = "button"; 1013 deleteItem.className = "dot-actions-item"; 1014 deleteItem.textContent = "Delete"; 1015 deleteItem.addEventListener("click", () => { 1016 promptDeleteDotType(dotType.id, dotType.name); 1017 closeDotMenus(); 1018 }); 1019 1020 const colorItem = document.createElement("button"); 1021 colorItem.type = "button"; 1022 colorItem.className = "dot-actions-item"; 1023 colorItem.textContent = "Change color"; 1024 colorItem.addEventListener("click", () => { 1025 closeDotMenus(); 1026 openColorPicker(colorPicker); 1027 }); 1028 1029 const permanentDeleteItem = document.createElement("button"); 1030 permanentDeleteItem.type = "button"; 1031 permanentDeleteItem.className = "dot-actions-item danger-solid"; 1032 permanentDeleteItem.textContent = "Permanently Delete"; 1033 permanentDeleteItem.addEventListener("click", () => { 1034 promptPermanentDeleteDotType(dotType.id, dotType.name); 1035 closeDotMenus(); 1036 }); 1037 1038 toggle.addEventListener("click", (event) => { 1039 event.stopPropagation(); 1040 const opening = menu.classList.contains("hidden"); 1041 closeDotMenus(); 1042 item.classList.toggle("menu-open", opening); 1043 if (opening) { 1044 showAnimated(menu); 1045 requestAnimationFrame(() => { 1046 if (!window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches) { 1047 positionDotActionsMenu(menu); 1048 } else { 1049 menu.style.removeProperty("--menu-offset-x"); 1050 menu.style.removeProperty("--menu-offset-y"); 1051 if (mobileMenuPortal && !menu.dataset.portalActive) { 1052 menu.dataset.portalActive = "true"; 1053 portalParents.set(menu, actions); 1054 mobileMenuPortal.appendChild(menu); 1055 } 1056 } 1057 updateMenuScrim(); 1058 }); 1059 } else { 1060 menu.classList.add("hidden"); 1061 updateMenuScrim(); 1062 } 1063 }); 1064 1065 if (inUse) { 1066 menu.append(renameItem, colorItem, permanentDeleteItem); 1067 } else { 1068 menu.append(renameItem, colorItem, deleteItem); 1069 } 1070 actions.append(toggle, menu); 1071 inputWrap.append(nameInput, actions); 1072 item.append(swatch, inputWrap, colorPicker); 1073 targetList.appendChild(item); 1074 }); 1075 1076 if (targetList === dotTypeList && state.hideSuggestions) { 1077 const addItem = document.createElement("li"); 1078 addItem.className = "dot-type-add-item"; 1079 const addButton = document.createElement("button"); 1080 addButton.type = "button"; 1081 addButton.className = "suggestion-chip add-new"; 1082 addButton.textContent = "Add New"; 1083 addButton.addEventListener("click", addNewDotType); 1084 addItem.appendChild(addButton); 1085 targetList.appendChild(addItem); 1086 } 1087 } 1088 1089 export function renderSuggestedDotTypes(targetList = suggestedDotList) { 1090 if (!targetList) return; 1091 targetList.innerHTML = ""; 1092 1093 SUGGESTED_DOT_TYPES.forEach((suggestion) => { 1094 if (hasDotTypeName(suggestion.name)) return; 1095 1096 const chip = document.createElement("button"); 1097 chip.type = "button"; 1098 chip.className = "suggestion-chip"; 1099 const chipSwatch = document.createElement("span"); 1100 chipSwatch.className = "swatch"; 1101 chipSwatch.style.background = suggestion.color; 1102 const chipLabel = document.createElement("span"); 1103 chipLabel.textContent = suggestion.name; 1104 chip.append(chipSwatch, chipLabel); 1105 chip.addEventListener("click", () => addSuggestedDotType(suggestion)); 1106 targetList.appendChild(chip); 1107 }); 1108 1109 const addNewChip = document.createElement("button"); 1110 addNewChip.type = "button"; 1111 addNewChip.className = "suggestion-chip add-new"; 1112 addNewChip.textContent = "Add New"; 1113 addNewChip.addEventListener("click", addNewDotType); 1114 targetList.appendChild(addNewChip); 1115 } 1116 1117 function restoreFocus() { 1118 if (lastFocusTrigger && typeof lastFocusTrigger.focus === "function") { 1119 lastFocusTrigger.focus({ preventScroll: true }); 1120 } 1121 lastFocusTrigger = null; 1122 } 1123 1124 export function openPopover(isoDate, x, y, contextMonthIso = null) { 1125 if (popoverHideTimer) { 1126 clearTimeout(popoverHideTimer); 1127 popoverHideTimer = null; 1128 } 1129 const shouldAnimateIn = popover.classList.contains("hidden"); 1130 lastFocusTrigger = document.activeElement; 1131 activePopover = { isoDate, contextMonthIso }; 1132 activeNoteEdit = null; 1133 document.body.classList.add("popover-open"); 1134 popover.innerHTML = ""; 1135 1136 const selectedIds = new Set(getDayDotIds(isoDate)); 1137 1138 const header = document.createElement("h1"); 1139 header.className = "popover-date"; 1140 const date = new Date(isoDate); 1141 const parts = new Intl.DateTimeFormat(undefined, { 1142 weekday: "short", 1143 day: "2-digit", 1144 month: "short" 1145 }).formatToParts(date); 1146 const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])); 1147 header.textContent = `${byType.weekday} ${byType.day} ${byType.month}`; 1148 popover.appendChild(header); 1149 1150 if (state.dotTypes.length === 0) { 1151 const empty = document.createElement("div"); 1152 empty.textContent = "Add a dot type first."; 1153 empty.className = "muted"; 1154 empty.style.padding = "8px"; 1155 popover.appendChild(empty); 1156 } 1157 1158 state.dotTypes.forEach((dotType) => { 1159 const node = popoverItemTemplate.content.firstElementChild.cloneNode(true); 1160 node.querySelector(".swatch").style.background = dotType.color; 1161 node.querySelector(".label").textContent = dotType.name; 1162 if (selectedIds.has(dotType.id)) { 1163 node.classList.add("selected"); 1164 } 1165 1166 node.addEventListener("click", () => { 1167 const wasSelected = selectedIds.has(dotType.id); 1168 toggleDot(isoDate, dotType.id); 1169 if (wasSelected) { 1170 openPopover(isoDate, x, y, contextMonthIso); 1171 } else { 1172 closePopover(); 1173 } 1174 }); 1175 1176 popover.appendChild(node); 1177 }); 1178 1179 const noteWrap = document.createElement("div"); 1180 noteWrap.className = "popover-note"; 1181 const addDotTypeButton = document.createElement("button"); 1182 addDotTypeButton.type = "button"; 1183 addDotTypeButton.className = "note-edit-button"; 1184 setButtonLabelWithShortcut(addDotTypeButton, "Add dot type", "D"); 1185 addDotTypeButton.addEventListener("click", () => { 1186 closePopover(); 1187 addNewDotType(); 1188 openSettingsModal(); 1189 }); 1190 const noteButton = document.createElement("button"); 1191 noteButton.type = "button"; 1192 noteButton.className = "note-edit-button"; 1193 setButtonLabelWithShortcut(noteButton, getDayNote(isoDate) ? "Edit note" : "Add note", "N"); 1194 noteButton.addEventListener("click", () => { 1195 closePopover(); 1196 startNoteEdit(isoDate, contextMonthIso, "", true); 1197 }); 1198 noteWrap.append(addDotTypeButton, noteButton); 1199 popover.appendChild(noteWrap); 1200 1201 popover.classList.remove("hidden"); 1202 const isSmallScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches; 1203 if (isSmallScreen) { 1204 popover.style.left = ""; 1205 popover.style.top = ""; 1206 } else { 1207 const maxX = window.innerWidth - popover.offsetWidth - 8; 1208 const maxY = window.innerHeight - popover.offsetHeight - 8; 1209 popover.style.left = `${clamp(x, 8, maxX)}px`; 1210 popover.style.top = `${clamp(y, 8, maxY)}px`; 1211 } 1212 if (shouldAnimateIn) { 1213 showAnimated(popover); 1214 } else { 1215 popover.classList.add("visible"); 1216 } 1217 showPopoverScrim(); 1218 } 1219 1220 export function closePopover() { 1221 activePopover = null; 1222 document.body.classList.remove("popover-open"); 1223 if (popoverHideTimer) { 1224 clearTimeout(popoverHideTimer); 1225 popoverHideTimer = null; 1226 } 1227 popover.classList.remove("visible"); 1228 popoverHideTimer = window.setTimeout(() => { 1229 popover.classList.add("hidden"); 1230 popoverHideTimer = null; 1231 }, POPOVER_ANIMATION_MS); 1232 hidePopoverScrim(); 1233 restoreFocus(); 1234 } 1235 1236 export function showPopoverScrim() { 1237 if (!popoverScrim) return; 1238 popoverScrim.classList.remove("hidden"); 1239 requestAnimationFrame(() => { 1240 popoverScrim.classList.add("visible"); 1241 }); 1242 } 1243 1244 export function hidePopoverScrim() { 1245 if (!popoverScrim) return; 1246 popoverScrim.classList.remove("visible"); 1247 window.setTimeout(() => { 1248 popoverScrim.classList.add("hidden"); 1249 }, POPOVER_ANIMATION_MS); 1250 } 1251 1252 export function startNoteEdit(isoDate, contextMonthIso = null, initialText = "", immediateFocus = false) { 1253 activeNoteEdit = isoDate; 1254 activeNoteEditMonthIso = contextMonthIso; 1255 const applyInitialText = (editor) => { 1256 if (!initialText) return; 1257 editor.textContent = `${editor.textContent || ""}${initialText}`; 1258 }; 1259 const focusEditor = (editor) => { 1260 if (!editor) return false; 1261 applyInitialText(editor); 1262 editor.focus(); 1263 const selection = window.getSelection(); 1264 if (selection) { 1265 selection.removeAllRanges(); 1266 const range = document.createRange(); 1267 range.selectNodeContents(editor); 1268 range.collapse(false); 1269 selection.addRange(range); 1270 } 1271 return true; 1272 }; 1273 const findEditor = () => { 1274 const scopedSelector = contextMonthIso 1275 ? `[data-note-editor="${isoDate}"][data-note-month="${contextMonthIso}"]` 1276 : null; 1277 const visibleContainer = isMobileView() ? monthGrid : yearGrid; 1278 const containerSelector = `[data-note-editor="${isoDate}"]`; 1279 const fallbackSelector = `[data-note-editor="${isoDate}"]`; 1280 return ( 1281 (scopedSelector && document.querySelector(scopedSelector)) || 1282 visibleContainer?.querySelector(containerSelector) || 1283 document.querySelector(fallbackSelector) 1284 ); 1285 }; 1286 1287 if (immediateFocus) { 1288 render(); 1289 if (focusEditor(findEditor())) return; 1290 } 1291 1292 requestRender(() => { 1293 requestAnimationFrame(() => { 1294 focusEditor(findEditor()); 1295 }); 1296 }); 1297 } 1298 1299 export function finishNoteEdit(isoDate, editor) { 1300 activeNoteEdit = null; 1301 activeNoteEditMonthIso = null; 1302 setDayNote(isoDate, editor.textContent || ""); 1303 } 1304 1305 export function buildNoteEditor(isoDate, baseClass, monthIso = null) { 1306 const editor = document.createElement("div"); 1307 editor.className = `${baseClass} note-editor`; 1308 editor.contentEditable = "true"; 1309 editor.spellcheck = true; 1310 editor.dataset.noteEditor = isoDate; 1311 if (monthIso) editor.dataset.noteMonth = monthIso; 1312 editor.textContent = getDayNote(isoDate); 1313 editor.addEventListener("input", (event) => { 1314 event.stopPropagation(); 1315 }); 1316 editor.addEventListener("keydown", (event) => { 1317 event.stopPropagation(); 1318 if (event.key === "Enter") { 1319 event.preventDefault(); 1320 finishNoteEdit(isoDate, editor); 1321 editor.blur(); 1322 } 1323 }); 1324 editor.addEventListener("blur", () => { 1325 finishNoteEdit(isoDate, editor); 1326 }); 1327 editor.addEventListener("pointerdown", (event) => { 1328 event.stopPropagation(); 1329 }); 1330 editor.addEventListener("click", (event) => { 1331 event.stopPropagation(); 1332 }); 1333 return editor; 1334 } 1335 1336 export function toggleDot(isoDate, dotId) { 1337 const ids = new Set(getDayDotIds(isoDate)); 1338 if (ids.has(dotId)) { 1339 ids.delete(dotId); 1340 clearDotPosition(isoDate, dotId); 1341 } else ids.add(dotId); 1342 1343 if (ids.size === 0) { 1344 delete state.dayDots[isoDate]; 1345 } else { 1346 state.dayDots[isoDate] = [...ids]; 1347 } 1348 1349 saveAndRender(); 1350 } 1351 1352 export function deleteDotType(dotId) { 1353 if (isDotTypeInUse(dotId)) return; 1354 1355 state.dotTypes = state.dotTypes.filter((d) => d.id !== dotId); 1356 1357 for (const [iso, ids] of Object.entries(state.dayDots)) { 1358 const next = ids.filter((id) => id !== dotId); 1359 clearDotPosition(iso, dotId); 1360 if (next.length === 0) delete state.dayDots[iso]; 1361 else state.dayDots[iso] = next; 1362 } 1363 1364 saveAndRender(); 1365 } 1366 1367 export function promptDeleteDotType(dotId, dotName) { 1368 if (isDotTypeInUse(dotId)) return; 1369 pendingDeleteDotTypeId = dotId; 1370 pendingDeleteDotTypeName = dotName; 1371 pendingDeleteMode = "safe"; 1372 deleteText.textContent = "You haven’t used this dot yet."; 1373 deleteModal.classList.remove("hidden"); 1374 } 1375 1376 export function promptPermanentDeleteDotType(dotId, dotName) { 1377 pendingDeleteDotTypeId = dotId; 1378 pendingDeleteDotTypeName = dotName; 1379 pendingDeleteMode = "force"; 1380 deleteText.textContent = `This will remove “${dotName}” from all days it is already applied to and delete it from your dot types.`; 1381 deleteModal.classList.remove("hidden"); 1382 } 1383 1384 export function closeDeleteModal() { 1385 pendingDeleteDotTypeId = null; 1386 pendingDeleteDotTypeName = ""; 1387 pendingDeleteMode = "safe"; 1388 deleteModal.classList.add("hidden"); 1389 } 1390 1391 export function confirmDeleteDotType() { 1392 if (!pendingDeleteDotTypeId) return; 1393 if (pendingDeleteMode === "force") { 1394 forceDeleteDotType(pendingDeleteDotTypeId); 1395 showToast(`Permanently deleted "${pendingDeleteDotTypeName}".`); 1396 } else { 1397 deleteDotType(pendingDeleteDotTypeId); 1398 showToast(`Deleted "${pendingDeleteDotTypeName}".`); 1399 } 1400 closeDeleteModal(); 1401 } 1402 1403 export function forceDeleteDotType(dotId) { 1404 state.dotTypes = state.dotTypes.filter((d) => d.id !== dotId); 1405 1406 for (const [iso, ids] of Object.entries(state.dayDots)) { 1407 const next = ids.filter((id) => id !== dotId); 1408 clearDotPosition(iso, dotId); 1409 if (next.length === 0) delete state.dayDots[iso]; 1410 else state.dayDots[iso] = next; 1411 } 1412 1413 saveAndRender(); 1414 } 1415 1416 export function closeSettingsModal() { 1417 if (settingsModalHideTimer) { 1418 clearTimeout(settingsModalHideTimer); 1419 settingsModalHideTimer = null; 1420 } 1421 settingsModal.classList.remove("visible"); 1422 settingsModalHideTimer = window.setTimeout(() => { 1423 settingsModal.classList.add("hidden"); 1424 settingsModalHideTimer = null; 1425 }, MODAL_ANIMATION_MS); 1426 closeDotMenus(); 1427 restoreFocus(); 1428 } 1429 1430 export function openSettingsModal() { 1431 if (settingsModalHideTimer) { 1432 clearTimeout(settingsModalHideTimer); 1433 settingsModalHideTimer = null; 1434 } 1435 lastFocusTrigger = document.activeElement; 1436 showAnimated(settingsModal); 1437 } 1438 1439 export function closePeriodMenu() { 1440 if (!periodPickerMenu) return; 1441 periodPickerMenu.classList.remove("visible"); 1442 periodPickerMenu.classList.add("hidden"); 1443 updateMenuScrim(); 1444 } 1445 1446 function getPeriodPickerItems() { 1447 return Array.from(periodPickerMenu.querySelectorAll(".period-picker-item")); 1448 } 1449 1450 function getFocusedPeriodPickerIndex(items) { 1451 const activeElement = document.activeElement; 1452 if (activeElement instanceof HTMLElement) { 1453 const focusedIndex = items.indexOf(activeElement); 1454 if (focusedIndex >= 0) return focusedIndex; 1455 } 1456 return items.findIndex((item) => item.classList.contains("active")); 1457 } 1458 1459 function focusPeriodPickerItem(items, index) { 1460 if (!items.length) return; 1461 const clampedIndex = clamp(index, 0, items.length - 1); 1462 const item = items[clampedIndex]; 1463 item.focus({ preventScroll: true }); 1464 item.scrollIntoView({ block: "nearest" }); 1465 } 1466 1467 function getFocusableElements(container) { 1468 if (!container) return []; 1469 const selector = [ 1470 "a[href]", 1471 "button:not([disabled])", 1472 "input:not([disabled])", 1473 "select:not([disabled])", 1474 "textarea:not([disabled])", 1475 "[tabindex]:not([tabindex='-1'])" 1476 ].join(", "); 1477 return Array.from(container.querySelectorAll(selector)).filter((element) => { 1478 if (!(element instanceof HTMLElement)) return false; 1479 if (element.getAttribute("aria-hidden") === "true") return false; 1480 if (element.closest(".hidden")) return false; 1481 return element.offsetParent !== null; 1482 }); 1483 } 1484 1485 function trapFocus(container, event) { 1486 const focusable = getFocusableElements(container); 1487 if (focusable.length === 0) { 1488 event.preventDefault(); 1489 return; 1490 } 1491 const activeElement = document.activeElement; 1492 const activeIndex = focusable.indexOf(activeElement); 1493 if (activeIndex === -1) { 1494 event.preventDefault(); 1495 (event.shiftKey ? focusable[focusable.length - 1] : focusable[0]).focus(); 1496 return; 1497 } 1498 if (!event.shiftKey && activeIndex === focusable.length - 1) { 1499 event.preventDefault(); 1500 focusable[0].focus(); 1501 return; 1502 } 1503 if (event.shiftKey && activeIndex === 0) { 1504 event.preventDefault(); 1505 focusable[focusable.length - 1].focus(); 1506 return; 1507 } 1508 } 1509 1510 function getPopoverDotItems() { 1511 return Array.from(popover.querySelectorAll(".popover-item")); 1512 } 1513 1514 function getFocusedPopoverDotIndex(items) { 1515 const activeElement = document.activeElement; 1516 if (activeElement instanceof HTMLElement) { 1517 const focusedIndex = items.indexOf(activeElement); 1518 if (focusedIndex >= 0) return focusedIndex; 1519 } 1520 const selectedIndex = items.findIndex((item) => item.classList.contains("selected")); 1521 return selectedIndex >= 0 ? selectedIndex : 0; 1522 } 1523 1524 function focusPopoverDotItem(items, index) { 1525 if (!items.length) return; 1526 const clampedIndex = clamp(index, 0, items.length - 1); 1527 const item = items[clampedIndex]; 1528 item.focus({ preventScroll: true }); 1529 item.scrollIntoView({ block: "nearest" }); 1530 } 1531 1532 export function openPeriodMenu() { 1533 showAnimated(periodPickerMenu); 1534 updateMenuScrim(); 1535 requestAnimationFrame(() => { 1536 const items = getPeriodPickerItems(); 1537 if (!items.length) return; 1538 const currentIndex = getFocusedPeriodPickerIndex(items); 1539 focusPeriodPickerItem(items, currentIndex >= 0 ? currentIndex : 0); 1540 }); 1541 } 1542 1543 export function addSuggestedDotType(suggestion) { 1544 if (hasDotTypeName(suggestion.name)) return; 1545 state.dotTypes.push({ 1546 id: crypto.randomUUID(), 1547 name: suggestion.name, 1548 color: suggestion.color 1549 }); 1550 saveAndRender(); 1551 showToast(`Added "${suggestion.name}".`); 1552 } 1553 1554 export function addNewDotType() { 1555 const dotId = crypto.randomUUID(); 1556 const dotName = "New Dot"; 1557 state.dotTypes.push({ 1558 id: dotId, 1559 name: dotName, 1560 color: getNextSuggestedColor() 1561 }); 1562 pendingFocusDotId = dotId; 1563 saveAndRender(); 1564 showToast(`Added "${dotName}".`); 1565 } 1566 1567 export function hasDotTypeName(name) { 1568 const target = normalizeDotTypeName(name).toLowerCase(); 1569 return state.dotTypes.some((dot) => normalizeDotTypeName(dot.name).toLowerCase() === target); 1570 } 1571 1572 function setButtonLabelWithShortcut(button, label, shortcut) { 1573 button.textContent = ""; 1574 const text = document.createElement("span"); 1575 text.className = "button-label"; 1576 text.textContent = label; 1577 const hint = document.createElement("span"); 1578 hint.className = "key-hint"; 1579 hint.setAttribute("aria-hidden", "true"); 1580 hint.textContent = shortcut; 1581 button.append(text, hint); 1582 button.setAttribute("aria-label", `${label} (${shortcut})`); 1583 } 1584 1585 function normalizeDotTypeColorInput(value) { 1586 const raw = String(value || "").trim(); 1587 if (!raw) return null; 1588 1589 const hexBody = raw.startsWith("#") ? raw.slice(1) : raw; 1590 if (/^[0-9a-fA-F]{3}$/.test(hexBody)) { 1591 const expanded = hexBody 1592 .split("") 1593 .map((char) => char + char) 1594 .join("") 1595 .toUpperCase(); 1596 return `#${expanded}`; 1597 } 1598 1599 if (/^[0-9a-fA-F]{6}$/.test(hexBody)) { 1600 return `#${hexBody.toUpperCase()}`; 1601 } 1602 1603 const namedColor = raw.toLowerCase(); 1604 if (!/^[a-z]+$/.test(namedColor)) return null; 1605 1606 if (typeof CSS !== "undefined" && typeof CSS.supports === "function") { 1607 if (CSS.supports("color", namedColor)) return namedColor; 1608 return null; 1609 } 1610 1611 const probe = document.createElement("span"); 1612 probe.style.color = ""; 1613 probe.style.color = namedColor; 1614 return probe.style.color ? namedColor : null; 1615 } 1616 1617 export function getNextSuggestedColor() { 1618 for (const suggestion of SUGGESTED_DOT_TYPES) { 1619 if (!state.dotTypes.some((dot) => dot.color.toLowerCase() === suggestion.color.toLowerCase())) { 1620 return suggestion.color; 1621 } 1622 } 1623 return "#000000"; 1624 } 1625 1626 export function isDotTypeInUse(dotId) { 1627 return dotTypeInUseSet.has(dotId); 1628 } 1629 1630 export function handlePeriodPickerScroll() { 1631 if (periodPickerMenu.classList.contains("hidden")) return; 1632 const nearBottom = 1633 periodPickerMenu.scrollTop + periodPickerMenu.clientHeight >= periodPickerMenu.scrollHeight - PERIOD_SCROLL_THRESHOLD; 1634 if (!nearBottom || periodLoadInProgress) return; 1635 1636 periodLoadInProgress = true; 1637 const previousScrollTop = periodPickerMenu.scrollTop; 1638 if (isMobileView()) loadedMobileMonthCount += MOBILE_MONTH_BATCH_SIZE; 1639 else loadedYearBatchCount += 1; 1640 renderPeriodPicker(true, previousScrollTop); 1641 requestAnimationFrame(() => { 1642 periodLoadInProgress = false; 1643 }); 1644 } 1645 1646 export function dismissPopoverFromScrim(event) { 1647 event?.preventDefault?.(); 1648 event?.stopPropagation?.(); 1649 closePopover(); 1650 suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_OPEN_MS; 1651 } 1652 1653 export function handleGlobalPointerDown(event) { 1654 if (!event.target.closest(".period-picker")) { 1655 closePeriodMenu(); 1656 } 1657 if (!event.target.closest(".dot-actions, .dot-actions-menu")) { 1658 closeDotMenus(); 1659 } 1660 if (!event.target.closest(".color-picker, .swatch")) { 1661 closeColorPickers(); 1662 } 1663 if (!settingsModal.classList.contains("hidden") && event.target === settingsModal) { 1664 closeSettingsModal(); 1665 return; 1666 } 1667 if (!deleteModal.classList.contains("hidden") && event.target === deleteModal) { 1668 closeDeleteModal(); 1669 return; 1670 } 1671 if (!activePopover) return; 1672 const insidePopover = popover.contains(event.target); 1673 const clickedDay = event.target.closest(".year-day, .month-day"); 1674 if (!insidePopover && clickedDay) { 1675 closePopover(); 1676 suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_CLOSE_MS; 1677 return; 1678 } 1679 if (!insidePopover && !clickedDay) { 1680 closePopover(); 1681 } 1682 } 1683 1684 export function handleGlobalKeyDown(event) { 1685 const target = event.target; 1686 const isEditableTarget = 1687 target instanceof HTMLElement && 1688 (target.isContentEditable || 1689 target.tagName === "INPUT" || 1690 target.tagName === "TEXTAREA" || 1691 target.tagName === "SELECT"); 1692 const isPlainShortcutKey = !event.metaKey && !event.ctrlKey && !event.altKey; 1693 const key = String(event.key || "").toLowerCase(); 1694 const isPeriodMenuOpen = !periodPickerMenu.classList.contains("hidden"); 1695 const isSettingsOpen = !settingsModal.classList.contains("hidden"); 1696 const isDotPopoverOpen = Boolean(activePopover) && !popover.classList.contains("hidden"); 1697 1698 if (isDotPopoverOpen && event.key === "Tab") { 1699 trapFocus(popover, event); 1700 } 1701 1702 if (isDotPopoverOpen && !isEditableTarget) { 1703 const dotItems = getPopoverDotItems(); 1704 const isArrowNavKey = 1705 event.key === "ArrowDown" || 1706 event.key === "ArrowUp" || 1707 event.key === "ArrowLeft" || 1708 event.key === "ArrowRight"; 1709 if (dotItems.length > 0 && isArrowNavKey) { 1710 event.preventDefault(); 1711 const currentIndex = getFocusedPopoverDotIndex(dotItems); 1712 const direction = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1; 1713 focusPopoverDotItem(dotItems, currentIndex + direction); 1714 return; 1715 } 1716 } 1717 1718 if (isSettingsOpen && event.key === "Tab") { 1719 trapFocus(settingsModal, event); 1720 } 1721 1722 if (isPeriodMenuOpen && !isEditableTarget) { 1723 const items = getPeriodPickerItems(); 1724 if (items.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) { 1725 event.preventDefault(); 1726 const currentIndex = getFocusedPeriodPickerIndex(items); 1727 const startIndex = currentIndex >= 0 ? currentIndex : 0; 1728 const direction = event.key === "ArrowDown" ? 1 : -1; 1729 focusPeriodPickerItem(items, startIndex + direction); 1730 return; 1731 } 1732 if (items.length > 0 && event.key === "Home") { 1733 event.preventDefault(); 1734 focusPeriodPickerItem(items, 0); 1735 return; 1736 } 1737 if (items.length > 0 && event.key === "End") { 1738 event.preventDefault(); 1739 focusPeriodPickerItem(items, items.length - 1); 1740 return; 1741 } 1742 if (items.length > 0 && (event.key === "Enter" || event.key === " ")) { 1743 event.preventDefault(); 1744 const currentIndex = getFocusedPeriodPickerIndex(items); 1745 const target = items[currentIndex >= 0 ? currentIndex : 0]; 1746 target.click(); 1747 return; 1748 } 1749 } 1750 1751 const isQuestionSettingsShortcut = isPlainShortcutKey && !isEditableTarget && event.key === "?"; 1752 if (isQuestionSettingsShortcut) { 1753 event.preventDefault(); 1754 closePopover(); 1755 openSettingsModal(); 1756 return; 1757 } 1758 1759 const isDesktopYearView = 1760 !isMobileView() && 1761 !appShell.classList.contains("hidden") && 1762 !yearGrid.classList.contains("hidden") && 1763 monthGrid.classList.contains("hidden"); 1764 const isYearArrowShortcut = 1765 isPlainShortcutKey && 1766 !isEditableTarget && 1767 !isPeriodMenuOpen && 1768 !isSettingsOpen && 1769 !isDotPopoverOpen && 1770 (event.key === "ArrowLeft" || event.key === "ArrowRight"); 1771 if (isDesktopYearView && isYearArrowShortcut) { 1772 event.preventDefault(); 1773 shiftYearBy(event.key === "ArrowRight" ? 1 : -1); 1774 return; 1775 } 1776 1777 const isYearShortcut = isPlainShortcutKey && !isEditableTarget && key === "y"; 1778 if (isYearShortcut) { 1779 event.preventDefault(); 1780 closePopover(); 1781 if (periodPickerMenu.classList.contains("hidden")) openPeriodMenu(); 1782 else closePeriodMenu(); 1783 return; 1784 } 1785 1786 const isAddDotTypeShortcut = isPlainShortcutKey && !isEditableTarget && activePopover && key === "d"; 1787 if (isAddDotTypeShortcut) { 1788 event.preventDefault(); 1789 closePopover(); 1790 addNewDotType(); 1791 openSettingsModal(); 1792 return; 1793 } 1794 1795 const isAddNoteShortcut = isPlainShortcutKey && !isEditableTarget && activePopover && key === "n"; 1796 if (isAddNoteShortcut) { 1797 event.preventDefault(); 1798 const { isoDate, contextMonthIso } = activePopover; 1799 closePopover(); 1800 startNoteEdit(isoDate, contextMonthIso || null, "", true); 1801 return; 1802 } 1803 1804 const isTypeToStartNote = 1805 !event.metaKey && 1806 !event.ctrlKey && 1807 !event.altKey && 1808 !isEditableTarget && 1809 activePopover && 1810 event.key.length === 1 && 1811 event.key !== " "; 1812 if (isTypeToStartNote) { 1813 event.preventDefault(); 1814 const { isoDate, contextMonthIso } = activePopover; 1815 closePopover(); 1816 startNoteEdit(isoDate, contextMonthIso || null, event.key); 1817 return; 1818 } 1819 1820 if (event.key !== "Escape") return; 1821 closePopover(); 1822 closePeriodMenu(); 1823 closeSettingsModal(); 1824 closeDeleteModal(); 1825 } 1826 1827 export function showOnboardingIfNeeded() { 1828 if (!hasEnteredApp) return; 1829 if (DEMO_MODE) return; 1830 try { 1831 if (localStorage.getItem(AUTH_STATE_KEY) !== "1") return; 1832 } catch { 1833 return; 1834 } 1835 try { 1836 if (localStorage.getItem(ONBOARDING_KEY) === "1") return; 1837 showOnboardingStep("intro"); 1838 onboardingModal?.classList.remove("hidden"); 1839 } catch { 1840 // Ignore storage access issues. 1841 } 1842 } 1843 1844 export function showOnboardingStep(step) { 1845 onboardingModal?.querySelectorAll(".onboarding-step").forEach((panel) => { 1846 panel.classList.toggle("hidden", panel.dataset.step !== step); 1847 }); 1848 onboardingModal?.querySelectorAll(".onboarding-dot").forEach((dot) => { 1849 dot.classList.toggle("active", dot.dataset.step === step); 1850 }); 1851 if (step === "dots") { 1852 renderOnboardingLists(); 1853 } 1854 } 1855 1856 export function closeOnboardingModal() { 1857 onboardingModal?.classList.add("hidden"); 1858 } 1859 1860 export function completeOnboarding() { 1861 try { 1862 localStorage.setItem(ONBOARDING_KEY, "1"); 1863 } catch { 1864 // ignore 1865 } 1866 closeOnboardingModal(); 1867 } 1868 1869 export function renderOnboardingLists() { 1870 renderDotTypeList(onboardingDotTypeList); 1871 renderSuggestedDotTypes(onboardingSuggestedDotList); 1872 } 1873 1874 export function enterApp({ skipOnboarding = false } = {}) { 1875 if (!DEMO_MODE) { 1876 try { 1877 if (localStorage.getItem(AUTH_STATE_KEY) !== "1") { 1878 showMarketingPage(); 1879 showLogin(); 1880 showToast("Sign in to access your diary."); 1881 return false; 1882 } 1883 } catch { 1884 showMarketingPage(); 1885 showLogin(); 1886 showToast("Sign in to access your diary."); 1887 return false; 1888 } 1889 } 1890 hasEnteredApp = true; 1891 loginMode = false; 1892 try { 1893 localStorage.setItem(APP_ENTRY_KEY, "1"); 1894 localStorage.setItem(VIEW_MODE_KEY, "app"); 1895 } catch { 1896 // ignore 1897 } 1898 marketingPage?.classList.add("hidden"); 1899 appShell?.classList.remove("hidden"); 1900 hasInitializedMobileMonthScroll = false; 1901 requestRender(); 1902 if (skipOnboarding) { 1903 try { 1904 localStorage.setItem(ONBOARDING_KEY, "1"); 1905 } catch { 1906 // ignore 1907 } 1908 closeOnboardingModal(); 1909 } else { 1910 showOnboardingIfNeeded(); 1911 } 1912 return true; 1913 } 1914 1915 export function startDotDrag(event, { isoDate, dotId, sticker, mode }) { 1916 event.preventDefault(); 1917 event.stopPropagation(); 1918 1919 const parent = sticker.parentElement; 1920 if (!parent) return; 1921 const pointerId = event.pointerId; 1922 let moved = false; 1923 1924 const updatePosition = (pointerEvent) => { 1925 const rect = parent.getBoundingClientRect(); 1926 const nextLeft = clamp(((pointerEvent.clientX - rect.left) / rect.width) * 100, 6, 94); 1927 const nextTop = clamp(((pointerEvent.clientY - rect.top) / rect.height) * 100, 12, 88); 1928 const current = getDotPosition(isoDate, dotId, mode); 1929 sticker.style.left = `${nextLeft}%`; 1930 sticker.style.top = `${nextTop}%`; 1931 sticker.style.transform = `translate(-50%, -50%) rotate(${current.rotate}deg)`; 1932 moved = true; 1933 return { nextLeft, nextTop }; 1934 }; 1935 1936 let last = null; 1937 const onMove = (moveEvent) => { 1938 if (moveEvent.pointerId !== pointerId) return; 1939 last = updatePosition(moveEvent); 1940 }; 1941 1942 const onUp = (upEvent) => { 1943 if (upEvent.pointerId !== pointerId) return; 1944 sticker.removeEventListener("pointermove", onMove); 1945 sticker.removeEventListener("pointerup", onUp); 1946 sticker.removeEventListener("pointercancel", onUp); 1947 if (moved && last) { 1948 saveDotPosition(isoDate, dotId, last.nextLeft, last.nextTop); 1949 saveAndRender(); 1950 suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_OPEN_MS; 1951 } 1952 }; 1953 1954 sticker.setPointerCapture(pointerId); 1955 sticker.addEventListener("pointermove", onMove); 1956 sticker.addEventListener("pointerup", onUp); 1957 sticker.addEventListener("pointercancel", onUp); 1958 } 1959 1960 export function closeDotMenus() { 1961 document.querySelectorAll(".dot-type-row.menu-open").forEach((row) => { 1962 row.classList.remove("menu-open"); 1963 }); 1964 document.querySelectorAll(".dot-actions-menu").forEach((menu) => { 1965 menu.classList.remove("visible"); 1966 menu.classList.add("hidden"); 1967 menu.style.removeProperty("--menu-offset-x"); 1968 menu.style.removeProperty("--menu-offset-y"); 1969 if (menu.dataset.portalActive === "true") { 1970 const parent = portalParents.get(menu); 1971 if (parent) parent.appendChild(menu); 1972 menu.dataset.portalActive = ""; 1973 } 1974 }); 1975 updateMenuScrim(); 1976 } 1977 1978 export function closeColorPickers() { 1979 document.querySelectorAll(".color-picker").forEach((picker) => { 1980 picker.classList.remove("visible"); 1981 picker.classList.add("hidden"); 1982 const row = hostRows.get(picker); 1983 if (row) { 1984 row.classList.remove("menu-open"); 1985 hostRows.delete(picker); 1986 } 1987 if (picker.dataset.portalActive === "true") { 1988 const parent = portalParents.get(picker); 1989 if (parent) parent.appendChild(picker); 1990 picker.dataset.portalActive = ""; 1991 } 1992 }); 1993 updateMenuScrim(); 1994 } 1995 1996 export function openColorPicker(picker) { 1997 const opening = picker.classList.contains("hidden"); 1998 closeColorPickers(); 1999 if (!opening) return; 2000 const hostRow = picker.closest(".dot-type-row"); 2001 if (hostRow) { 2002 hostRow.classList.add("menu-open"); 2003 hostRows.set(picker, hostRow); 2004 } 2005 const hexInput = hexInputs.get(picker); 2006 const colorFn = currentColors.get(picker); 2007 if (hexInput && colorFn) { 2008 hexInput.value = colorFn(); 2009 } 2010 const isMobile = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches; 2011 if (isMobile && mobileMenuPortal && !picker.dataset.portalActive) { 2012 picker.dataset.portalActive = "true"; 2013 portalParents.set(picker, picker.parentElement); 2014 mobileMenuPortal.appendChild(picker); 2015 } 2016 showAnimated(picker); 2017 updateMenuScrim(); 2018 } 2019 2020 export function buildColorPicker(dotType, swatch) { 2021 const picker = document.createElement("div"); 2022 picker.className = "color-picker hidden"; 2023 picker.dataset.portal = "color-picker"; 2024 currentColors.set(picker, () => dotType.color); 2025 2026 const grid = document.createElement("div"); 2027 grid.className = "color-grid"; 2028 const palette = COLOR_PALETTE.filter((color, index, arr) => arr.indexOf(color) === index); 2029 palette.forEach((color) => { 2030 const btn = document.createElement("button"); 2031 btn.type = "button"; 2032 btn.className = "color-swatch"; 2033 btn.style.background = color; 2034 btn.setAttribute("aria-label", `Use ${color}`); 2035 btn.addEventListener("click", () => { 2036 const changed = dotType.color !== color; 2037 dotType.color = color; 2038 saveAndRender(); 2039 if (changed) { 2040 showToast(`Changed color for "${dotType.name}".`); 2041 } 2042 closeColorPickers(); 2043 }); 2044 grid.appendChild(btn); 2045 }); 2046 2047 const customRow = document.createElement("div"); 2048 customRow.className = "color-custom"; 2049 2050 const hexLabel = document.createElement("label"); 2051 hexLabel.className = "sr-only"; 2052 hexLabel.textContent = "Custom color"; 2053 const hexInput = document.createElement("input"); 2054 hexInput.type = "text"; 2055 hexInput.value = dotType.color; 2056 hexInput.placeholder = "#RRGGBB or red"; 2057 hexInput.className = "color-hex-input"; 2058 hexInput.setAttribute("aria-label", "Custom color hex value"); 2059 hexInputs.set(picker, hexInput); 2060 2061 const applyButton = document.createElement("button"); 2062 applyButton.type = "button"; 2063 applyButton.textContent = "Apply"; 2064 applyButton.className = "color-apply"; 2065 applyButton.addEventListener("click", () => { 2066 const normalized = normalizeDotTypeColorInput(hexInput.value); 2067 if (!normalized) { 2068 showToast("Enter a valid color name or hex color."); 2069 return; 2070 } 2071 const changed = dotType.color.toLowerCase() !== normalized.toLowerCase(); 2072 dotType.color = normalized; 2073 swatch.style.background = normalized; 2074 saveAndRender(); 2075 if (changed) { 2076 showToast(`Changed color for "${dotType.name}".`); 2077 } 2078 closeColorPickers(); 2079 }); 2080 2081 customRow.append(hexInput, applyButton); 2082 picker.append(grid, customRow); 2083 return picker; 2084 } 2085 2086 export function positionDotActionsMenu(menu) { 2087 const boundaryRect = menu.closest(".settings-card, .modal-card")?.getBoundingClientRect() || { 2088 left: 8, 2089 top: 8, 2090 right: window.innerWidth - 8, 2091 bottom: window.innerHeight - 8 2092 }; 2093 const padding = 8; 2094 const rect = menu.getBoundingClientRect(); 2095 let offsetX = 0; 2096 let offsetY = 0; 2097 2098 if (rect.left < boundaryRect.left + padding) { 2099 offsetX = boundaryRect.left + padding - rect.left; 2100 } else if (rect.right > boundaryRect.right - padding) { 2101 offsetX = boundaryRect.right - padding - rect.right; 2102 } 2103 2104 if (rect.bottom > boundaryRect.bottom - padding) { 2105 offsetY = boundaryRect.bottom - padding - rect.bottom; 2106 } 2107 2108 menu.style.setProperty("--menu-offset-x", `${offsetX}px`); 2109 menu.style.setProperty("--menu-offset-y", `${offsetY}px`); 2110 } 2111 2112 export function updateMenuScrim() { 2113 if (!menuScrim) return; 2114 const isMobileSheet = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches; 2115 const hasDotMenu = Boolean(document.querySelector(".dot-actions-menu:not(.hidden)")); 2116 const hasColorPicker = Boolean(document.querySelector(".color-picker:not(.hidden)")); 2117 const hasPeriodMenu = !periodPickerMenu.classList.contains("hidden"); 2118 const shouldShow = isMobileSheet && (hasDotMenu || hasPeriodMenu || hasColorPicker); 2119 if (menuScrimHideTimer) { 2120 clearTimeout(menuScrimHideTimer); 2121 menuScrimHideTimer = null; 2122 } 2123 if (shouldShow) { 2124 menuScrim.classList.remove("hidden"); 2125 requestAnimationFrame(() => { 2126 menuScrim.classList.add("visible"); 2127 }); 2128 return; 2129 } 2130 menuScrim.classList.remove("visible"); 2131 menuScrimHideTimer = window.setTimeout(() => { 2132 menuScrim.classList.add("hidden"); 2133 menuScrimHideTimer = null; 2134 }, MENU_SCRIM_HIDE_MS); 2135 } 2136 2137 export function showAnimated(element) { 2138 element.classList.remove("hidden"); 2139 element.classList.remove("visible"); 2140 void element.offsetWidth; 2141 requestAnimationFrame(() => { 2142 requestAnimationFrame(() => { 2143 element.classList.add("visible"); 2144 }); 2145 }); 2146 } 2147 2148 export function syncDotTypeInputSize(input) { 2149 const value = input.value || " "; 2150 const style = window.getComputedStyle(input); 2151 const sizer = syncDotTypeInputSize._sizer || document.createElement("span"); 2152 if (!syncDotTypeInputSize._sizer) { 2153 sizer.style.position = "absolute"; 2154 sizer.style.visibility = "hidden"; 2155 sizer.style.whiteSpace = "pre"; 2156 sizer.style.pointerEvents = "none"; 2157 sizer.style.left = "-9999px"; 2158 sizer.style.top = "0"; 2159 document.body.appendChild(sizer); 2160 syncDotTypeInputSize._sizer = sizer; 2161 } 2162 sizer.style.font = style.font; 2163 sizer.style.letterSpacing = style.letterSpacing; 2164 sizer.textContent = value; 2165 2166 const measured = Math.ceil(sizer.getBoundingClientRect().width) + 3; 2167 input.style.width = `${measured}px`; 2168 } 2169 2170 export function applyTheme() { 2171 document.documentElement.dataset.theme = isDarkModeEnabled() ? "dark" : "light"; 2172 } 2173 2174 export function downloadDataExport() { 2175 const payload = { 2176 version: 1, 2177 exportedAt: new Date().toISOString(), 2178 data: state 2179 }; 2180 const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); 2181 const url = URL.createObjectURL(blob); 2182 const anchor = document.createElement("a"); 2183 anchor.href = url; 2184 anchor.download = `dot-diary-export-${new Date().toISOString().slice(0, 10)}.json`; 2185 document.body.appendChild(anchor); 2186 anchor.click(); 2187 anchor.remove(); 2188 URL.revokeObjectURL(url); 2189 showToast("Downloaded your data."); 2190 } 2191 2192 export async function handleDataImport(event) { 2193 const file = event.target.files?.[0]; 2194 if (!file) return; 2195 try { 2196 const raw = await file.text(); 2197 const parsed = JSON.parse(raw); 2198 const next = normalizeImportedState(parsed?.data ?? parsed); 2199 setState(next); 2200 loadedYearBatchCount = 1; 2201 loadedMobileMonthCount = 24; 2202 closePeriodMenu(); 2203 closeDotMenus(); 2204 saveAndRender(); 2205 showToast("Imported your data."); 2206 } catch { 2207 showToast("Could not import that file."); 2208 } finally { 2209 event.target.value = ""; 2210 } 2211 } 2212 2213 export function isDarkModeEnabled() { 2214 if (typeof state.darkMode === "boolean") { 2215 return state.darkMode; 2216 } 2217 return window.matchMedia("(prefers-color-scheme: dark)").matches; 2218 } 2219 2220 export function handleResetOnboarding() { 2221 try { 2222 localStorage.removeItem(STORAGE_KEY); 2223 localStorage.removeItem(ONBOARDING_KEY); 2224 localStorage.removeItem(AUTH_STATE_KEY); 2225 } catch { 2226 // ignore 2227 } 2228 closeSettingsModal(); 2229 setState(structuredClone(defaultState)); 2230 saveAndRender(); 2231 resetToLoggedOut(); 2232 showOnboardingIfNeeded(); 2233 } 2234 2235 export function setupDevAutoReload() { 2236 if (!DEV_HOSTS.has(window.location.hostname)) return; 2237 const files = [ 2238 "index.html", 2239 "styles.css", 2240 "src/app.js", 2241 "src/constants.js", 2242 "src/dom.js", 2243 "src/utils.js", 2244 "src/state.js", 2245 "src/ui.js", 2246 "src/auth.js", 2247 "src/toast.js" 2248 ]; 2249 let lastHash = ""; 2250 const poll = async () => { 2251 try { 2252 const contents = await Promise.all( 2253 files.map((file) => 2254 fetch(`${file}?t=${Date.now()}`, { cache: "no-store" }) 2255 .then((response) => (response.ok ? response.text() : "")) 2256 .catch(() => "") 2257 ) 2258 ); 2259 const combined = contents.join("||"); 2260 if (!combined) return; 2261 const nextHash = String(hash32(combined)); 2262 if (lastHash && nextHash !== lastHash) { 2263 window.location.reload(); 2264 return; 2265 } 2266 lastHash = nextHash; 2267 } catch { 2268 // ignore polling errors 2269 } 2270 }; 2271 poll(); 2272 window.setInterval(poll, DEV_POLL_MS); 2273 }