1Object.values = Object.values || function (o) { return Object.keys(o).map(function (k) { return o[k] }) }; 2 3// Track which todos have already been rendered to avoid redundant operations 4let renderedTodos = new Set(); 5// Cache for todo DOM elements to reduce jQuery selector overhead 6let todoElementCache = {}; 7// Using window.initialLoadInProgress from todo-base.js 8// debouncedSave is defined in todo-base.js 9 10var uiOptions = { 11 collapsed: [], 12 showCompleted: true, 13 filters: [] 14} 15 16function loadUIOptions() { 17 var opts = JSON.parse(localStorage.getItem('uioptions')); 18 if (opts) { 19 uiOptions.filters = (opts.filters ?? []).filter((value, index, array) => array.indexOf(value) === index) 20 uiOptions.showCompleted = opts.showCompleted ?? true 21 uiOptions.collapsed = (opts.collapsed ?? []).filter((value, index, array) => getTodoById(value) && array.indexOf(value) === index) 22 } 23 return uiOptions; 24} 25 26function saveUIOptions() { 27 localStorage.setItem('uioptions', JSON.stringify(uiOptions)); 28} 29 30/** 31 * Show a centered modal confirmation dialog. Resolves to true on confirm, false 32 * on cancel/backdrop click/Escape. Replaces native confirm() for consistency. 33 * 34 * opts: { message, confirmText, cancelText, variant = 'danger' } 35 */ 36function confirmModal(opts) { 37 const message = (opts && opts.message) || ''; 38 const variant = (opts && opts.variant) || 'danger'; 39 const confirmLabel = (opts && opts.confirmText) 40 || (window.i18n && (variant === 'danger' ? window.i18n['common.delete'] : window.i18n['common.confirm'])) 41 || (variant === 'danger' ? 'Delete' : 'Confirm'); 42 const cancelLabel = (opts && opts.cancelText) 43 || (window.i18n && window.i18n['common.cancel']) 44 || 'Cancel'; 45 46 return new Promise(resolve => { 47 const $modal = $('#confirm-modal'); 48 if ($modal.length === 0) { 49 // Fall back to native if the modal DOM isn't present for some reason. 50 resolve(window.confirm(message)); 51 return; 52 } 53 54 $('#confirm-modal-message').text(message); 55 const $confirmBtn = $('#confirm-modal-confirm') 56 .text(confirmLabel) 57 .removeClass('btn-danger btn-primary btn-warning btn-success') 58 .addClass('btn-' + variant); 59 $('#confirm-modal-cancel').text(cancelLabel); 60 61 const close = (result) => { 62 $modal.css('display', 'none'); 63 $confirmBtn.off('click.confirmModal'); 64 $('#confirm-modal-cancel').off('click.confirmModal'); 65 $('#confirm-modal-backdrop').off('click.confirmModal'); 66 $(document).off('keydown.confirmModal'); 67 resolve(result); 68 }; 69 70 $confirmBtn.on('click.confirmModal', () => close(true)); 71 $('#confirm-modal-cancel').on('click.confirmModal', () => close(false)); 72 $('#confirm-modal-backdrop').on('click.confirmModal', () => close(false)); 73 $(document).on('keydown.confirmModal', (e) => { 74 if (e.key === 'Escape') { e.preventDefault(); close(false); } 75 else if (e.key === 'Enter') { e.preventDefault(); close(true); } 76 }); 77 78 $modal.css('display', 'flex'); 79 setTimeout(() => $confirmBtn.trigger('focus'), 0); 80 }); 81} 82 83// Format a stored deadline (datetime-local string like "2024-03-15T15:00") into 84// a short human-readable label for the on-item box. Returns '' for invalid input. 85function formatDeadline(value) { 86 if (!value) return ''; 87 const d = new Date(value); 88 if (isNaN(d.getTime())) return ''; 89 try { 90 return d.toLocaleString(undefined, { 91 month: 'short', 92 day: 'numeric', 93 hour: 'numeric', 94 minute: '2-digit' 95 }); 96 } catch (e) { 97 return value; 98 } 99} 100 101function deadlineIsOverdue(value) { 102 if (!value) return false; 103 const d = new Date(value); 104 if (isNaN(d.getTime())) return false; 105 return d.getTime() < Date.now(); 106} 107 108// Build a click-to-edit category emoji for a top-level todo. The emoji itself 109// is a Bootstrap dropdown toggle; the menu shows a quick-pick row for emojis 110// already in this list (one tap to re-use) followed by a self-contained 111// categorized emoji picker. All emoji rendering is delegated to the OS; no 112// image assets or metadata are shipped. 113function renderCategoryPicker(todo, innerWrapper) { 114 const $container = $('<span class="dropdown todocategory-wrap mx-1 my-1"></span>'); 115 // autoClose="outside" keeps the menu open while the user clicks between 116 // category tabs; selecting an emoji still closes it via our explicit 117 // closeMenu() call in buildOption's click handler. 118 const $toggle = $('<button type="button" class="todocategory btn btn-sm p-0 border-0 bg-transparent" data-bs-toggle="dropdown" data-bs-display="static" data-bs-auto-close
118="outside" aria-haspopup="true" aria-expanded="false"></button>') 119 .text(todo.Category); 120 const $menu = $('<div class="dropdown-menu dropdown-menu-end p-2 category-picker-menu"></div>'); 121 122 const closeMenu = () => { 123 if (typeof bootstrap !== 'undefined') { 124 bootstrap.Dropdown.getOrCreateInstance($toggle[0]).hide(); 125 } 126 }; 127 128 const buildOption = (emoji) => { 129 const isCurrent = emoji === todo.Category; 130 return $('<button type="button" class="btn btn-sm category-option"></button>') 131 .addClass(isCurrent ? 'btn-primary' : 'btn-outline-secondary') 132 .text(emoji) 133 .attr('aria-label', emoji) 134 .on('click', function () { 135 applyCategory(todo, emoji); 136 closeMenu(); 137 }); 138 }; 139 140 // One-tap "already used" row. 141 const usedEmojis = [...new Set(TodoStore.getTodos().map(t => t.category).filter(Boolean))]; 142 if (usedEmojis.length > 0) { 143 const $usedLabel = $('<div class="category-picker-label text-muted small mb-1"></div>') 144 .text(window.i18n['todo.category.inUse'] || 'In your list'); 145 $menu.append($usedLabel); 146 const $usedRow = $('<div class="category-suggestions d-flex flex-wrap mb-2"></div>'); 147 usedEmojis.forEach(emoji => $usedRow.append(buildOption(emoji).addClass('me-1 mb-1'))); 148 $menu.append($usedRow); 149 $menu.append('<div class="dropdown-divider my-2"></div>'); 150 } 151 152 // Categorized picker: a grid that swaps content as the user clicks tabs. 153 const categories = (typeof window !== 'undefined' && window.EMOJI_CATEGORIES) || []; 154 const $pickerWrap = $('<div class="emoji-picker-wrap"></div>'); 155 const $grid = $('<div class="emoji-picker-grid" role="tabpanel"></div>'); 156 const $tabBar = $('<div class="emoji-picker-tabs" role="tablist"></div>'); 157 158 const renderGrid = (emojis) => { 159 $grid.empty(); 160 emojis.forEach(emoji => $grid.append(buildOption(emoji).addClass('emoji-picker-cell'))); 161 }; 162 163 categories.forEach((cat, idx) => { 164 const $tab = $('<button type="button" class="emoji-picker-tab" role="tab"></button>') 165 .attr('aria-label', cat.label) 166 .attr('title', cat.label) 167 .text(cat.icon) 168 .on('click', function () { 169 $tabBar.find('.emoji-picker-tab').removeClass('active'); 170 $(this).addClass('active'); 171 renderGrid(cat.emojis); 172 }); 173 if (idx === 0) $tab.addClass('active'); 174 $tabBar.append($tab); 175 }); 176 177 if (categories.length > 0) renderGrid(categories[0].emojis); 178 179 $pickerWrap.append($tabBar, $grid); 180 $menu.append($pickerWrap); 181 182 $container.append($toggle, $menu); 183 innerWrapper.append($container); 184} 185 186function applyCategory(todo, newCategory) { 187 if (!newCategory || newCategory === todo.Category) return; 188 TodoStore.dispatch('UPDATE_TODO', { 189 todoId: todo.Id || todo.id, 190 updates: { category: newCategory } 191 }); 192 save(); 193 renderedTodos.delete(todo.Id); 194 loadTodo(todo, true); 195 // Refresh the global category filter UI so the new emoji shows up as a 196 // filter option and stale ones drop out. 197 if (typeof generateCategoryCheckboxes === 'function') generateCategoryCheckboxes(); 198} 199 200function clearDeadlineForTodo(todo) { 201 // Direct assignment via the setter â matches the clearEstimate pattern. 202 // Dispatching UPDATE_TODO with { deadline: undefined } is a no-op because 203 // the dispatcher's `!== undefined` guard skips undefined values. 204 todo.Deadline = undefined; 205 save(); 206 renderedTodos.delete(todo.Id); 207 loadTodo(todo, true); 208} 209 210$(document).ready(async function () { 211 const spicinessSlider = $('#spiciness'); 212 const spicycontainer = $('#spicycontainer'); 213 214 const spicinessWrapper = $('#spiciness-wrapper'); 215 const spicinessToggle = $('#spiciness-toggle'); 216 217 218 // Get spicinessLevel from local storage if it exists 219 const storedSpicinessLevel = localStorage.getItem('spiciness'); 220 if (storedSpicinessLevel) { 221 spicinessSlider.val(storedSpicinessLevel); 222 setSpicinessLevel(storedSpicinessLevel); 223 } 224 $('html').on('click', function () { 225 if (spicinessWrapper.is(":visible"))
226 spicinessWrapper.toggle(); 227 }); 228 229 230 231 spicinessWrapper.on("input", function (e) { 232 const level = spicinessSlider.val(); 233 setSpicinessLevel(level) 234 235 localStorage.setItem('spiciness', level); 236 237 e.stopPropagation() 238 }); 239 spicinessWrapper.on("click", function (e) { 240 const level = spicinessSlider.val(); 241 setSpicinessLevel(level) 242 243 localStorage.setItem('spiciness', level); 244 245 e.stopPropagation() 246 }); 247 spicinessToggle.on('click', function (e) { 248 spicinessWrapper.toggle(); 249 e.stopPropagation() 250 }); 251 252 spicycontainer.removeClass('invisible') 253 $("#add-item").on('click', () => createTodo(null)); 254 $("#new-item").on('keyup', function (e) { 255 if (e.key === 'Enter' || e.keyCode === 13) { 256 createTodo(null); 257 } 258 }); 259 260 // Add this event listener to the end of your script section: 261 $("#categoryFilterMenu").on("click", function (e) { 262 filterTodosByCategories(); 263 e.stopPropagation() 264 }); 265 266 window.initialLoadInProgress = true; // Ensure flag is set before loading todos 267 loadUIOptions(); 268 269 // Show loading indicator below the todo list 270 $('#todo-list').after('<div id="loading-indicator" class="mt-4 p-3 bg-light rounded border"><div class="d-flex align-items-center"><div class="spinner-border text-primary me-3" role="status"></div><div class="loading-message">Loading todos...</div></div>'); 271 272 // Wait for the IDB-backed boot to populate TodoStore before rendering. 273 if (window.todoBootReady) { 274 try { await window.todoBootReady; } catch (e) { console.error('todoBootReady error', e); } 275 } 276 277 // Replace loadTodo() with optimized initial load 278 console.time('optimizedInitialLoad'); 279 await optimizedInitialLoad(); 280 console.timeEnd('optimizedInitialLoad'); 281 282 // Remove loading indicator 283 $('#loading-indicator').remove(); 284 285 window.initialLoadInProgress = false; // Reset flag after initial load is complete 286}); 287 288// New function for optimized initial loading with depth-first traversal 289async function optimizedInitialLoad() { 290 // Render the tab bar before the list so active-tab scoping is in effect 291 if (typeof renderTabs === 'function') renderTabs(); 292 293 // Get top-level items for the active tab only 294 const activeTabId = TodoStore.getActiveTabId(); 295 const topLevel = getTopLevel().filter(t => t.tabId === activeTabId); 296 297 // Make sure the container exists 298 if (!$('#todo-list').find('> ul').length) { 299 $('#todo-list').append('<ul class="list-unstyled border border-1"></ul>'); 300 } 301 302 // Update loading message 303 $('#loading-indicator .loading-message').text('Loading todos...'); 304 305 console.log("Starting depth-first loading of todos"); 306 307 // Count total todos for progress tracking 308 const totalTodos = TodoStore.getTodos().length; 309 let processedCount = 0; 310 311 // Process each top-level todo and all its children before moving to the next top-level todo 312 for (const todo of topLevel) { 313 if (!todo) continue; 314 315 // Render this root todo and then immediately process its entire subtree 316 await loadTodoDepthFirst(todo); 317 318 // Update progress 319 processedCount++; 320 const percentComplete = Math.round((processedCount / topLevel.length) * 100); 321 $('#loading-indicator .loading-message').text(`Loading todos: ${percentComplete}% complete`); 322 323 // Show progress in the console 324 console.log(`Loaded ${todo.text} and all its children`); 325 } 326 327 console.log("All todos loaded completely"); 328 329 // Set up initial UI state 330 if (topLevel.length > 0) { 331 $(".export").show(); 332 } else { 333 $(".export").hide(); 334 } 335 336 // Set up draggable elements at the end of everything 337 debouncedEnableDraggable(); 338 339 // Generate categories and filters after all todos are loaded 340 generateCategoryCheckboxes(); 341 filterTodosByCategories(); 342} 343
344// Helper function to load a todo and all its descendants depth-first 345async function loadTodoDepthFirst(todo) { 346 if (!todo) return; 347 348 // Skip if already rendered 349 if (renderedTodos.has(todo.Id)) return; 350 351 // Render this todo 352 createTodoItem(todo); 353 renderedTodos.add(todo.Id); 354 355 // Get all children 356 const children = getChildrenById(todo.Id); 357 358 // Process each child and its descendants before moving to the next child 359 // Process in small batches to avoid locking the UI for too long 360 const batchSize = 5; 361 for (let i = 0; i < children.length; i += batchSize) { 362 const batch = children.slice(i, i + batchSize); 363 await Promise.all(batch.map(async child => await loadTodoDepthFirst(child))); 364 365 // Add a tiny delay between batches to allow UI to respond 366 if (children.length > batchSize && i + batchSize < children.length) { 367 await new Promise(resolve => setTimeout(resolve, 0)); 368 } 369 } 370 371 // Try to categorize if needed (but not if we already tried and struck out 372 // this session â otherwise every render re-hammers the category API). 373 if (!todo.hasAParent() && !todo.hasACategory() && !categorisationAttempted(todo)) { 374 categoriseTodo(todo); 375 } 376} 377 378 379function setSpicinessLevel(level) { 380 let chilis = ""; 381 for (let i = 0; i < level; i++) { 382 chilis += "ð¶ï¸"; 383 } 384 $('#spiciness-toggle').text(chilis); 385} 386 387function showSpinner() { 388 $('#add-item .spinner-border').css('display', 'inline-block'); 389 $('#add-item .fas').css('display', 'none'); 390} 391 392function hideSpinner() { 393 $('#add-item .spinner-border').css('display', 'none'); 394 $('#add-item .fas').css('display', 'inline-block'); 395} 396// Get cached todo element to avoid repeated jQuery selectors 397function getTodoElement(todoId) { 398 if (!todoElementCache[todoId]) { 399 todoElementCache[todoId] = $(`#todo-${todoId}`); 400 } 401 return todoElementCache[todoId]; 402} 403 404// Clear rendered tracking when major changes occur 405function clearRenderedTracking() { 406 renderedTodos.clear(); 407 todoElementCache = {}; 408} 409 410async function loadTodo(todo, forceUpdate = false) { 411 if (todo) { 412 // Accept either casing, and bail if there's no id at all. 413 // 414 // Without this guard an idless todo calls loadTodoById(undefined) â 415 // and because that function's signature is `id = null`, a default 416 // parameter fires for undefined and turns it into the "render 417 // everything from the root" case. Each root child then recurses back 418 // into the same path, so one malformed object blows the stack and 419 // freezes the tab rather than just failing to draw a row. 420 const todoId = todo.Id ?? todo.id; 421 if (todoId == null) return; 422 await loadTodoById(todoId, forceUpdate) 423 } 424 else await loadTodoById(null, forceUpdate) 425} 426 427// Track when draggable needs to be re-enabled 428let draggableInitialized = false; 429let draggablePending = false; 430let draggableDebounceTimer = null; 431 432// Debounced version of enableDraggable 433function debouncedEnableDraggable() { 434 if (draggableDebounceTimer) { 435 clearTimeout(draggableDebounceTimer); 436 } 437 438 draggablePending = true; 439 draggableDebounceTimer = setTimeout(() => { 440 if (draggablePending) { 441 enableDraggable(); 442 draggablePending = false; 443 } 444 draggableDebounceTimer = null; 445 }, 500); // Wait 500ms before initializing draggable 446} 447 448async function loadTodoById(id = null, forceUpdate = false) { 449 // Skip if already rendered and not forced to update 450 if (id !== null && renderedTodos.has(id) && !forceUpdate) { 451 return; // Early exit for already rendered todos 452 } 453 454 // Mark as rendered if it's a specific todo 455 if (id !== null) { 456 renderedTodos.add(id); 457 } 458 459 if (id) { 460 const todo = getTodoById(id) 461 if (todo) { 462 createTodoItem(todo); 463 // Don't call enableDraggable() here, mark as pending instead 464 draggablePending = true; 465 } 466 else { 467 if (todoElementCache[id]) { 468 delete todoElementCache[id]; 469 } 470 $(`#todo-${id}`).remove(); 471 renderedTodos.delete(id); // Remove from rendered set if todo no longer exists 472 // Don't call enableDraggable() here, mark as pending instead 473 draggablePending = true; 474 } 475 } 476 477 // Add loading indicator below the todo list if processing a large number of todos 478 const showLoading = id === null && TodoStore.getTodos().length > 100; 479 if (showLoading && !$('#batch-loading-indicator').length) { 480 $('#todo-list').after('<div id="batch-loading-indicator" class="mt-4 p-3 bg-light rounded border"><div class="d-flex align-items-center"><div class="spinner-border text-primary me-3" role="status"></div><div class="loading-message">Loading todos...</div></div></div>'); 481 } 482 483 var todoElements = $("#todo-list").find('.list-group-item') 484 485 todoElements.each(function () { 486 const id = $(this)?.attr('id')?.replace('todo-', ''); 487 if (id) { 488 const todo = getTodoById(id); 489 if (!todo) { 490 $(`#todo-${id}`).remove(); 491 } 492 } 493 });
494 495 // Get all child todos. For top-level (id == null) scope to the active tab only. 496 var children = getChildrenById(id); 497 if (id == null || id == undefined) { 498 var activeTabId = TodoStore.getActiveTabId(); 499 children = children.filter(function(t) { return t.tabId === activeTabId; }); 500 } 501 502 // For large sets (like initial load or importing), process in batches 503 if (children.length > 100) { 504 // Update loading message if shown 505 if ($('#batch-loading-indicator').length) { 506 $('#batch-loading-indicator .loading-message').text(`Loading ${children.length} todos...`); 507 } 508 509 // Process children in manageable batches 510 const batchSize = 50; 511 for (let i = 0; i < children.length; i += batchSize) { 512 const batch = children.slice(i, i + batchSize); 513 514 // Process this batch in parallel 515 await Promise.all(batch.map(child => loadTodo(child))); 516 517 // Update progress 518 const progress = Math.min(100, Math.round(((i + batch.length) / children.length) * 100)); 519 if ($('#batch-loading-indicator').length) { 520 $('#batch-loading-indicator .loading-message').text(`Loading todos... ${progress}% complete`); 521 } 522 523 // Yield to UI thread to prevent freezing 524 await new Promise(resolve => setTimeout(resolve, 0)); 525 } 526 } else { 527 // For smaller sets, process all in parallel 528 await Promise.all(children.map(child => loadTodo(child))); 529 } 530 531 if (id == null || id == undefined) { // Perform the rest of the operations only for top-level items 532 if (children.length > 0) { 533 $(".export").show(); 534 } else { 535 $(".export").hide(); 536 } 537 children.forEach(x => { 538 if (!x.hasACategory() && !categorisationAttempted(x)) { 539 categoriseTodo(x); 540 } 541 }); 542 generateCategoryCheckboxes(); 543 filterTodosByCategories() 544 } else { 545 var td = getTodoById(id) 546 if (td && !td.hasAParent() && !td.hasACategory() && !categorisationAttempted(td)) { 547 categoriseTodo(td) 548 } 549 } 550 551 // Remove loading indicator if it was shown 552 if (id === null && $('#batch-loading-indicator').length) { 553 $('#batch-loading-indicator').remove(); 554 } 555 556 // Request draggable update after batch is complete 557 if (id === null) { 558 debouncedEnableDraggable(); 559 } else { 560 draggablePending = true; 561 } 562} 563 564async function createTodo(parentId) { 565 let newItem; 566 if (parentId == null || parentId == undefined) { 567 newItem = $('#new-item')?.val()?.trim(); 568 } else { 569 newItem = $(`#new-subtask-input-${parentId}`)?.val()?.trim(); 570 if (!newItem) return; 571 } 572 if (window.clearTranscript) 573 window.clearTranscript() 574 if (!newItem) return; 575 showSpinner(); 576 var todo = addTodo(parentId, newItem); 577 if (parentId == null || parentId == undefined) { 578 $('#new-item').val(''); 579 await loadTodo(todo, true) // Force update to show the new todo 580 hideSpinner() 581 // Enable draggable after adding a new top-level todo 582 debouncedEnableDraggable(); 583 } else { 584 // createTodoItem(todo); 585 $(`#add-subtask-group-${parentId}`).remove(); 586 await loadTodo(todo, true) // Force update to show the new subtask 587 hideSpinner() // Hide spinner after adding a subtask too 588 // Enable draggable after adding a subtask 589 debouncedEnableDraggable(); 590 } 591} 592 593function createNewSubtaskInput(parentId) { 594 const parentListItem = $(`#todo-${parentId}`); 595 Uncollapse(parentId) 596 const subtaskInput = $(`<input data-voice id="new-subtask-input-${parentId}" type="text" class="edit-input form-control" placeholder="Add new subtask...">`); 597 598 const buttonWrapper = $('<div class="d-inline-flex pt-1" style="width: 70px;"></div>'); 599 600 const addButton = $(`<button id="add-subtask-button-${parentId}" type="button" class="btn btn-sm btn-primary edit-confirm add-subtask-cancel-confirm" style="width: 32px; height: 32px;" data-tooltip="Add item"><i class="fas fa-check"></i></button>`); 601 // const addButton = $(`<button id="add-subtask-button-${parentId}" type="button" class="btn btn-primary rounded-end"><i class="fas fa-plus"></i></button>`); 602 const cancelButton = $(`<button type="button" id="add-subtask-button-cancel" class="btn btn-sm btn-danger edit-cancel add-subtask-cancel" style="width: 32px; height: 32px;" data-tooltip="Cancel"><i class="fas fa-times"></i></button>`); 603 buttonWrapper.append(addButton, cancelButton) 604 605 subtaskInput.on('keyup', function (e) { 606 if (e.key === 'Enter' || e.keyCode === 13) { 607 createTodo(parentId); 608 inputGroup.remove(); 609 } 610 });
611 612 const inputGroup = $(`<div id="add-subtask-group-${parentId}" class="mb-2 mt-1 edit-input-container d-flex"></div>`); 613 614 addButton.on('click', function () { 615 createTodo(parentId); 616 inputGroup.remove(); 617 }); 618 619 cancelButton.on('click', function () { 620 inputGroup.remove(); 621 if (!parentListItem.find('> ul > li').length) { 622 parentListItem.find('> ul').remove() 623 } 624 }); 625 626 inputGroup.append(subtaskInput, buttonWrapper); 627 628 if (!parentListItem.find('> ul').length) { 629 parentListItem.append('<ul class="list-unstyled mt-2 ms-3" style="min-height: 1.5rem;"></ul>'); 630 } 631 parentListItem.find('> ul').append(inputGroup); 632 subtaskInput.focus(); 633 634 // window.addVoiceRecognition(subtaskInput); 635} 636 637 638 639async function clearChildren(todo) { 640 const operation = async () => { 641 $(`#todo-${todo.Id} > ul`).remove() 642 await deleteChildren(todo) 643 loadTodo(todo, true) // Force update to show cleared state 644 }; 645 646 return TodoOperations.enqueue('clear-children', todo.Id, operation); 647} 648 649async function clearAllTasks() { 650 const activeTabId = TodoStore.getActiveTabId(); 651 const toDelete = TodoStore.getTodos().filter(t => t.tabId === activeTabId); 652 if (toDelete.length === 0) return; 653 const msg = ((window.i18n && window.i18n['todo.clearAllConfirm']) || 'Delete all {count} items in this tab?') 654 .replace('{count}', toDelete.length); 655 const ok = await confirmModal({ message: msg }); 656 if (!ok) return; 657 658 const remaining = TodoStore.getTodos().filter(t => t.tabId !== activeTabId); 659 TodoStore.dispatch('REPLACE_ALL', { todos: remaining }); 660 // allowEmpty: clearing the only tab legitimately leaves zero todos; without 661 // this the persist guard refuses the write and the items resurrect on reload. 662 save(false, { allowEmpty: true }); 663 clearRenderedTracking(); 664 loadTodo(); 665} 666 667 668async function clearAllCompletedTasks() { 669 const activeTabId = TodoStore.getActiveTabId(); 670 const completed = TodoStore.getTodos().filter(t => t.tabId === activeTabId && t.isCompleted); 671 if (completed.length === 0) return; 672 const msg = ((window.i18n && window.i18n['todo.clearCompletedConfirm']) || 'Delete {count} completed items?') 673 .replace('{count}', completed.length); 674 const ok = await confirmModal({ message: msg }); 675 if (!ok) return; 676 677 // Remove each completed item together with its entire subtree (matching 678 // deleteTodo's semantics). Filtering on the completion flag alone left the 679 // incomplete children of a completed parent behind with a dangling 680 // parentId â invisible orphans that lingered in the store forever. 681 const toRemove = new Set(); 682 for (const t of completed) { 683 toRemove.add(t.id); 684 for (const d of TodoStore.getDescendants(t.id)) { 685 toRemove.add(d.id); 686 } 687 } 688 const remaining = TodoStore.getTodos().filter(t => !toRemove.has(t.id)); 689 TodoStore.dispatch('REPLACE_ALL', { todos: remaining }); 690 // allowEmpty: if every item in the only tab was completed, remaining is 691 // empty and that is intentional â let it persist rather than be refused. 692 save(false, { allowEmpty: true }); 693 clearRenderedTracking(); 694 loadTodo(); 695} 696 697async function clearAllEstimates() { 698 const activeTabId = TodoStore.getActiveTabId(); 699 const targetTodos = TodoStore.getTodos().filter(t => t.tabId === activeTabId); 700 for (const todo of targetTodos) { 701 // Dispatching { estimate: undefined } is a no-op â the reducer skips 702 // undefined fields. Clear through the setter instead, the same path the 703 // single-item "Clear estimate" button uses, so it actually takes. 704 todo.Estimate = undefined; 705 } 706 save(); 707 clearRenderedTracking(); 708 loadTodo(); 709} 710 711async function uncheckAllTasks() { 712 const activeTabId = TodoStore.getActiveTabId(); 713 const targetTodos = TodoStore.getTodos().filter(t => t.tabId === activeTabId); 714 for (const todo of targetTodos) { 715 TodoStore.dispatch('UPDATE_TODO', { 716 todoId: todo.id, 717 updates: { isCompleted: false }, 718 skipHistory: true 719 }); 720 } 721 save(); 722 clearRenderedTracking(); 723 loadTodo(); 724} 725 726async function EstimateAll() { 727 const activeTabId = TodoStore.getActiveTabId(); 728 // Only kick off estimation for top-level items. estimateTodo() already 729 // recurses into children (estimating leaves first, then rolling their 730 // totals up into the parent), so iterating over every todo made the same 731 // child get estimated twice concurrently â once directly, once via its 732 // parent's rollup â racing duplicate /api/Estimator calls with a 733 // last-writer-wins result. Serialising per root also stops the whole tab 734 // from stampeding the API at once. 735 const roots = TodoStore.getTodos().filter(t => t.tabId === activeTabId && !t.parentId); 736 for (const root of roots) { 737 // typeof check (not `!x.Estimate`) so a deliberate 0 ("no time needed") 738 // isn't treated as unestimated and redone. Roots with children are 739 // always rolled up so any unestimated descendants get filled
739in. 740 if (hasChildren(root.Id || root.id) || typeof root.Estimate !== 'number') { 741 await estimateTodo(root); 742 } 743 } 744 save(); 745 clearRenderedTracking(); 746 loadTodo(); 747} 748 749async function breakdownTodo(todo) { 750 // Guard against rapid repeated clicks. The operation queue would otherwise 751 // serialize them, producing duplicate subtasks once each breakdown runs. 752 if (typeof TodoOperations !== "undefined" && TodoOperations.isRunning('breakdown', todo.Id)) { 753 return; 754 } 755 756 const operation = async () => { 757 toggleBreakdownSpinner(todo.Id, true); 758 759 const newItem = todo.text; 760 if (!newItem) { 761 toggleBreakdownSpinner(todo.Id, false); 762 return; 763 } 764 765 var parents = [] 766 var parentId = todo.parentId 767 while (parentId) { 768 var parent = getTodoById(parentId); 769 parents.push(parent.text) 770 parentId = parent.parentId 771 } 772 773 const breakdown = await fetchStepByStepBreakdown(newItem, parents); 774 console.log('[breakdown] raw response for', todo.Id, ':', breakdown); 775 776 // Unwrap either a raw array or the legacy `{ steps: [...] }` shape. 777 let steps = breakdown; 778 if (steps && !Array.isArray(steps) && Array.isArray(steps.steps)) { 779 steps = steps.steps; 780 } 781 782 if (!steps || !Array.isArray(steps) || steps.length === 0) { 783 // Either the API call itself failed (null) or the server returned 784 // nothing usable (empty array / unrecognized shape). fetchStepByStepBreakdown 785 // already shows a toast on network errors, so only double-toast on 786 // the empty-but-successful case. 787 if (steps !== null && (Array.isArray(steps) && steps.length === 0)) { 788 error('Could not break this task down â try rewording it or adjusting the spiciness slider.'); 789 } 790 console.warn('[breakdown] no usable steps returned; breakdown =', breakdown); 791 toggleBreakdownSpinner(todo.Id, false); 792 return; 793 } 794 795 var subtaskList = $(`#todo-${todo.Id} > ul`); 796 if (subtaskList.length) { 797 Uncollapse(todo.Id) 798 subtaskList.empty(); 799 } 800 801 if (renderedTodos.has(todo.Id)) { 802 renderedTodos.delete(todo.Id); 803 } 804 805 let i = 0; 806 steps.forEach(x => { 807 i++; 808 addTodo(todo.Id, x, i, { defer: true }); // one save() after the loop 809 }); 810 save(); 811 812 // Force update this todo to show new children 813 await loadTodo(todo, true); 814 815 // Re-initialize draggable for the new subtasks 816 debouncedEnableDraggable(); 817 818 // Turn off spinner after successful processing 819 toggleBreakdownSpinner(todo.Id, false); 820 }; 821 822 return TodoOperations.enqueue('breakdown', todo.Id, operation); 823} 824 825 826function toggleBreakdownSpinner(todoId, show) { 827 const breakdownButton = $(`#todo-${todoId} .magic`).first(); 828 const breakdownIcon = $(`#todo-${todoId} .magic i`).first() 829 const breakdownSpinner = $(`#todo-${todoId}`).find('.spinner:first') 830 831 if (show) { 832 breakdownIcon.css('display', 'none'); 833 breakdownSpinner.css('display', 'inline-block'); 834 } else { 835 breakdownIcon.css('display', 'inline-block'); 836 breakdownSpinner.css('display', 'none'); 837 } 838} 839 840function togglePrioritizeOverlay(show) { 841 $('#prioritize-overlay').css('display', show ? 'flex' : 'none'); 842} 843 844// Rebuild the flat todos array such that items matching `groupFilter` are reordered 845// according to `newOrderIndices`, dragging their descendant subtrees along with them 846// to preserve the DFS invariant of the flat array. 847function reorderWithSubtrees(groupFilter, newOrderIndices) { 848 const current = TodoStore.getTodos(); 849 const groupMembers = current.filter(groupFilter); 850 if (groupMembers.length === 0) return null; 851 if (!Array.isArray(newOrderIndices) || newOrderIndices.length !== groupMembers.length) return null; 852 853 const subtrees = groupMembers.map(m => { 854 const ids = new Set([m.id, ...TodoStore.getDescendants(m.id).map(d => d.id)]); 855 return current.filter(t => ids.has(t.id)); 856 });
857 858 const reorderedSubtrees = newOrderIndices.map(i => subtrees[i]); 859 const flat = reorderedSubtrees.flat(); 860 const flatIds = new Set(flat.map(t => t.id)); 861 862 const result = []; 863 let injected = false; 864 for (const t of current) { 865 if (flatIds.has(t.id)) { 866 if (!injected) { result.push(...flat); injected = true; } 867 // else: skip, already emitted as part of the injected block 868 } else { 869 result.push(t); 870 } 871 } 872 return result; 873} 874 875// ============================================================================= 876// TAB BAR 877// ============================================================================= 878 879function renderTabs() { 880 const tabs = TodoStore.getTabs(); 881 const activeId = TodoStore.getActiveTabId(); 882 const $list = $('#tab-list'); 883 if ($list.length === 0) return; 884 $list.empty(); 885 886 tabs.slice().sort((a, b) => a.order - b.order).forEach(tab => { 887 const isActive = tab.id === activeId; 888 const $li = $('<li class="nav-item"></li>'); 889 const $btn = $('<button type="button" class="nav-link"></button>') 890 .toggleClass('active', isActive) 891 .attr('data-tab-id', tab.id) 892 .text(tab.name) 893 .on('click', () => switchTab(tab.id)); 894 $li.append($btn); 895 $list.append($li); 896 }); 897 898 const addLabel = (window.i18n && window.i18n['todo.tabs.addTooltip']) || 'New tab'; 899 const $addLi = $('<li class="nav-item"></li>'); 900 const $addBtn = $('<button type="button" id="add-tab-btn" class="nav-link"><i class="fas fa-plus"></i></button>') 901 .attr('aria-label', addLabel) 902 .attr('data-tooltip', addLabel) 903 .on('click', addTab); 904 $addLi.append($addBtn); 905 $list.append($addLi); 906 907 // "Delete tab" is always visible for consistency, but disabled
907when only one 908 // tab remains (you can't delete the last tab). 909 $('#delete-tab-btn').prop('disabled', tabs.length <= 1); 910 911 // "Remove empty tabs" appears only when there's more than one tab AND at least 912 // one is empty (no todo carries its id) â otherwise there's nothing to clean up. 913 var occ = {}; 914 (TodoStore.getTodos() || []).forEach(function (t) { if (t && t.tabId) occ[t.tabId] = true; }); 915 var anyEmpty = tabs.some(function (t) { return !occ[t.id]; }); 916 $('#remove-empty-tabs-item').toggle(tabs.length > 1 && anyEmpty); 917} 918 919async function switchTab(tabId) { 920 if (!tabId || tabId === TodoStore.getActiveTabId()) return; 921 TodoStore.dispatch('SET_ACTIVE_TAB', { tabId }); 922 // Await the IDB write so a rapid second tab-switch doesn't queue a 923 // half-completed save against a now-stale activeTabId. The Web Lock 924 // in TodoDB.saveAll already serializes against peer tabs; awaiting 925 // here serializes against the same tab. 926 await save(); 927 renderTabs(); 928 // Wipe and redraw â simplest + correct for tab switches. 929 clearRenderedTracking(); 930 $('#todo-list > ul').empty(); 931 await loadTodo(); 932 debouncedEnableDraggable(); 933} 934 935function addTab() { 936 // Clear any in-progress tab edit so we only have one input at a time. 937 $('#tab-list .tab-edit-li').remove(); 938 $('#tab-list .tab-edit').each(function () { renderTabs(); }); 939 940 // Drop a transient <li> before the + button and put it in edit mode. The 941 // tab is only actually created if the user confirms with a non-empty name. 942 const $addLi = $('#add-tab-btn').closest('li'); 943 if ($addLi.length === 0) return; 944 945 const $tempLi = $('<li class="nav-item tab-edit-li"></li>'); 946 $addLi.before($tempLi); 947 948 startInlineTabEdit({ 949 container: $tempLi, 950 initialValue: '', 951 onConfirm: (name) => { 952 const tabs = TodoStore.getTabs(); 953 const nextOrder = tabs.reduce((m, t) => Math.max(m, t.order), -1) + 1; 954 const newTab = { 955 id: generateTabId(), 956 name: name, 957 order: nextOrder, 958 timestamp: Date.now() 959 }; 960 TodoStore.dispatch('ADD_TAB', { tab: newTab }); 961 TodoStore.dispatch('SET_ACTIVE_TAB', { tabId: newTab.id }); 962 save(); 963 renderTabs(); 964 clearRenderedTracking(); 965 $('#todo-list > ul').empty(); 966 loadTodo(); 967 }, 968 onCancel: () => { 969 // Nothing committed â just remove the transient LI. 970 $tempLi.remove(); 971 } 972 }); 973} 974 975function renameActiveTab() { 976 const tabs = TodoStore.getTabs(); 977 const activeId = TodoStore.getActiveTabId(); 978 const tab = tabs.find(t => t.id === activeId); 979 if (!tab) return; 980 981 // Abort any other in-progress tab edit first. 982 $('#tab-list .tab-edit-li').remove(); 983 984 const $activeLi = $(`#tab-list .nav-link[data-tab-id="${activeId}"]`).closest('li'); 985 if ($activeLi.length === 0) return; 986 987 startInlineTabEdit({ 988 container: $activeLi, 989 initialValue: tab.name || '', 990 onConfirm: (name) => { 991 if (name === tab.name) { renderTabs(); return; } 992 TodoStore.dispatch('UPDATE_TAB', { tabId: activeId, updates: { name } }); 993 save(); 994 renderTabs(); 995 }, 996 onCancel: () => { 997 // Revert to whatever the store currently says. 998 renderTabs(); 999 } 1000 }); 1001} 1002 1003/** 1004 * Replace a tab-bar <li>'s contents with an inline edit UI: text input + green 1005 * check (confirm) + red X (cancel), matching the todo-item edit pattern. The 1006 * confirm button is disabled while the input is empty. Enter confirms, Escape 1007 * cancels. 1008 */ 1009function startInlineTabEdit({ container, initialValue, onConfirm, onCancel }) { 1010 container.empty(); 1011 1012 const $wrap = $('<span class="d-inline-flex align-items-center tab-edit py-1 px-2"></span>'); 1013 const $input = $('<input type="text" class="form-control form-control-sm tab-edit-input" />').val(initialValue || ''); 1014 const $confirmBtn = $('<button type="button" class="btn btn-sm btn-success edit-confirm ms-1" style="width: 28px; height: 28px; padding: 0;" aria-label="Confirm"><i class="fas fa-check"></i></button>'); 1015 const $cancelBtn = $('<button type="button" class="btn btn-sm btn-danger edit-cancel ms-1" style="width: 28px; height: 28px; padding: 0;" aria-label="Cancel"><i class="fas fa-times"></i></button>'); 1016 1017 $wrap.append($input, $confirmBtn, $cancelBtn); 1018 container.append($wrap); 1019 1020 const updateConfirmState = () => { 1021 $confirmBtn.prop('disabled', $input.val().trim().length === 0); 1022 }; 1023 updateConfirmState(); 1024 1025 $input.on('input', updateConfirmState); 1026 $input.on('keydown', (e) => { 1027 if (e.key === 'Enter') { 1028 e.preventDefault(); 1029 if (!$confirmBtn.prop('disabled')) $confirmBtn.trigger('click'); 1030 } else if (e.key === 'Escape') { 1031 e.preventDefault(); 1032 $cancelBtn.trigger('click'); 1033 } 1034 });
1035 1036 $confirmBtn.on('click', () => { 1037 const name = $input.val().trim(); 1038 if (!name) return; 1039 if (onConfirm) onConfirm(name); 1040 }); 1041 1042 $cancelBtn.on('click', () => { 1043 if (onCancel) onCancel(); 1044 }); 1045 1046 setTimeout(() => { 1047 $input.trigger('focus'); 1048 if (initialValue) { 1049 const el = $input[0]; 1050 if (el && typeof el.select === 'function') el.select(); 1051 } 1052 }, 0); 1053} 1054 1055async function deleteActiveTab() { 1056 const tabs = TodoStore.getTabs(); 1057 if (tabs.length <= 1) return; 1058 const activeId = TodoStore.getActiveTabId(); 1059 const count = TodoStore.getTodos().filter(t => t.tabId === activeId).length; 1060 const template = count > 0 1061 ? ((window.i18n && window.i18n['todo.tabs.deleteWithItemsConfirm']) || 'Delete this tab and its {count} items?') 1062 : ((window.i18n && window.i18n['todo.tabs.deleteEmptyConfirm']) || 'Delete this tab?'); 1063 const msg = template.replace('{count}', count); 1064 const ok = await confirmModal({ message: msg }); 1065 if (!ok) return; 1066 1067 TodoStore.dispatch('DELETE_TAB', { tabId: activeId }); 1068 save(); 1069 renderTabs(); 1070 clearRenderedTracking(); 1071 $('#todo-list > ul').empty(); 1072 loadTodo(); 1073} 1074 1075// Manual "clean up chaff": delete every EMPTY tab (no todo carries its id), 1076// keeping at least one tab. Exposed via the Tools menu, shown only when there's 1077// more than one tab and at least one is empty (see renderTabs). Safe by 1078// construction â only zero-todo tabs are deleted, so DELETE_TAB's cascade removes 1079// no items â and it undoes as a single step. The plain (pushable) save() 1080// propagates the removals to the account via the normal sync shadow-diff. 1081async function removeEmptyTabs() { 1082 const tabs = TodoStore.getTabs() || []; 1083 if (tabs.length <= 1) return; 1084 1085 const occupied = {}; 1086 (TodoStore.getTodos() || []).forEach(t => { if (t && t.tabId) occupied[t.tabId] = true; }); 1087 1088 const empties = tabs.filter(t => !occupied[t.id]); 1089 if (empties.length === 0) return; 1090 1091 // Never delete the last tab: if EVERY tab is empty, keep one (the active tab 1092 // if it's among them, else the lowest-order) and delete the rest. 1093 let toDelete = empties; 1094 if (empties.length === tabs.length) { 1095 const activeId = TodoStore.getActiveTabId(); 1096 const keeper = empties.find(e => e.id === activeId) 1097 || empties.slice().sort((a, b) => (a.order || 0) - (b.order || 0))[0]; 1098 toDelete = empties.filter(e => e.id !== keeper.id); 1099 } 1100 if (toDelete.length === 0) return; 1101 1102 const msg = ((window.i18n && window.i18n['todo.tabs.removeEmptyConfirm']) || 'Delete {count} empty tab(s)?') 1103 .replace('{count}', toDelete.length); 1104 const ok = await confirmModal({ message: msg }); 1105 if (!ok) return; 1106 1107 // Single undo entry for the whole cleanup: the FIRST delete snapshots history 1108 // (capturing all the empty tabs), the rest skip it, so one undo restores them all. 1109 toDelete.forEach((t, i) => TodoStore.dispatch('DELETE_TAB', { tabId: t.id, skipHistory: i > 0 })); 1110 save(); 1111 renderTabs(); 1112 clearRenderedTracking(); 1113 $('#todo-list > ul').empty(); 1114 loadTodo(); 1115} 1116 1117async function prioritizeList() { 1118 if (typeof TodoOperations !== "undefined" && TodoOperations.isRunning('prioritize')) return; 1119 1120 const operation = async () => { 1121 const activeTabId = TodoStore.getActiveTabId(); 1122 const topLevel = TodoStore.getTodos().filter(t => !t.parentId && t.tabId === activeTabId); 1123 if (topLevel.length < 2) return; 1124 1125 togglePrioritizeOverlay(true); 1126 try { 1127 const snapshotIds = topLevel.map(t => t.id); 1128 const { ok, order } = await fetchPrioritizedOrder(topLevel.map(t => t.text), []); 1129 if (!ok || !order) { 1130 error(window.i18n['todo.prioritizeFailed'] || 'Could not prioritize the list, try again later'); 1131 return; 1132 } 1133 1134 const nowTopLevel = TodoStore.getTodos().filter(t => !t.parentId && t.tabId === activeTabId); 1135 if (nowTopLevel.length !== snapshotIds.length || 1136 nowTopLevel.some((t, i) => t.id !== snapshotIds[i])) { 1137 error(window.i18n['todo.prioritizeFailed'] || 'Could not prioritize the list, try again later'); 1138 return; 1139 } 1140 1141 const newTodos = reorderWithSubtrees(t => !t.parentId && t.tabId === activeTabId, order); 1142 if (!newTodos) return; 1143 1144 TodoStore.dispatch('REORDER', { todos: newTodos }); 1145 // Immediate, not debounced: see updateTodoOrder. A debounced save 1146 // reads the live store when it fires, so a sync REORDER landing in 1147 // the window would persist the reverted order instead of this one. 1148 save(); 1149 if (typeof syncDomWithStore === 'function') { 1150 syncDomWithStore(); 1151 } else { 1152 clearRenderedTracking(); 1153 await loadTodo(null, true); 1154 } 1155 debouncedEnableDraggable(); 1156 } finally { 1157 togglePrioritizeOverlay(false); 1158 } 1159 }; 1160 1161 return TodoOperations.enqueue('prioritize', null, operation); 1162} 1163 1164async function prioritizeSubtasks(parentTodo) { 1165 if (typeof TodoOperations !== "undefined" && TodoOperations.isRunning('prioritize', parentTodo.Id)) return; 1166 1167 const operation = async () => { 1168 const children = TodoStore.getChildren(parentTodo.Id); 1169 if (children.length < 2) return; 1170 1171 togglePrioritizeOverlay(true); 1172 try { 1173 const ancestors = [parentTodo.text]; 1174 let pid = parentTodo.parentId; 1175 while (pid) { 1176 const p = getTodoById(pid); 1177 if (!p) break; 1178 ancestors.push(p.text); 1179 pid = p.parentId; 1180 } 1181 1182 const snapshotIds = children.map(c => c.id); 1183 const { ok, order } = await fetchPrioritizedOrder(children.map(c => c.text), ancestors); 1184 if (!ok || !order) { 1185 error(window.i18n['todo.prioritizeFailed'] || 'Could not prioritize the list, try again later'); 1186 return; 1187 } 1188 1189 const nowChildren = TodoStore.getChildren(parentTodo.Id); 1190 if (nowChildren.length !== snapshotIds.length || 1191 nowChildren.some((c, i) => c.id !== snapshotIds[i])) { 1192 error(window.i18n['todo.prioritizeFailed'] || 'Could not prioritize the list, try again later'); 1193 return; 1194 } 1195 1196 const newTodos = reorderWithSubtrees(t => t.parentId === parentTodo.Id, order); 1197 if (!newTodos) return; 1198 1199 TodoStore.dispatch('REORDER', { todos: newTodos }); 1200 // Immediate, not debounced: see updateTodoOrder. A debounced save 1201 // reads the live store when it fires, so a sync REORDER landing in 1202 // the window would persist the reverted order instead of this one. 1203 save(); 1204 if (typeof syncDomWithStore === 'function') { 1205 syncDomWithStore(); 1206 } else { 1207 clearRenderedTracking(); 1208 await loadTodo(null, true); 1209 } 1210 debouncedEnableDraggable(); 1211 } finally { 1212 togglePrioritizeOverlay(false); 1213 } 1214 }; 1215 1216 return TodoOperations.enqueue('prioritize', parentTodo.Id, operation); 1217} 1218 1219 1220 1221function createTodoItem(todo) { 1222 // Defensive tab gate. Async API callbacks (categorize, estimate, 1223 // breakdown) re-render their todo when the response arrives â but 1224 // by then the user may have switched to a different internal tab. 1225 // Without this guard, the late render appends a new <li> into 1226 // whichever tab's <ul> is currently mounted, leaking items across 1227 // tabs. 1228 // 1229 // We bail when: 1230 // - the todo has an active tab assignment that doesn't match 1231 // the currently-displayed tab, AND 1232 // - it isn't already in the DOM (so a forced *update* of an 1233 // already-shown item still works). 1234 // 1235 // For subtasks the test runs against the *root* ancestor's tabId, 1236 // because subtasks live inside their parent's <ul>: if the root 1237 // belongs to another tab, the subtask must not render either. 1238 if (typeof TodoStore !== 'undefined' && TodoStore.getActiveTabId) { 1239 var activeTabId = TodoStore.getActiveTabId(); 1240 // Walk up to find the root's tabId. For top-level items this 1241 // is just todo.tabId; for subtasks it's the ancestor's tabId. 1242 var rootTabId = todo.tabId; 1243 var cursor = todo; 1244 var safety = 0; 1245 while (cursor && cursor.parentId && safety < 50) { 1246 var parent = (typeof getTodoById === 'function') ? getTodoById(cursor.parentId) : null; 1247 if (!parent) break; 1248 cursor = parent; 1249 rootTabId = cursor.tabId || rootTabId; 1250 safety++; 1251 } 1252 if (activeTabId && rootTabId && rootTabId !== activeTabId 1253 && $(`#todo-${todo.Id}`).length === 0) { 1254 console.warn('[createTodoItem entry-guard] skipped â todo', todo.Id, 1255 'rootTabId=', rootTabId, 'activeTabId=', activeTabId); 1256 return; 1257 } 1258 } 1259 1260 // Clear cache entry when rebuilding 1261 if (todoElementCache[todo.Id]) { 1262 delete todoElementCache[todo.Id];
1263 } 1264 1265 var listItem = $(`#todo-${todo.Id}`) 1266 var subtaskList = $(`#todo-${todo.Id} > ul`).clone(false); 1267 1268 var itemFound = false 1269 if (listItem.length == 0) { 1270 listItem = $(`<li id="todo-${todo.Id}" class="list-group-item"></li>`); 1271 } 1272 else { 1273 itemFound = true 1274 listItem.empty() 1275 } 1276 1277 // Add to cache after creation 1278 todoElementCache[todo.Id] = listItem; 1279 const todoContent = $(`<div id="todo-text-${todo.Id}" class="list-group-item-content d-flex justify-content-between" ></div>`); 1280 1281 const leftContent = $('<div class="d-flex align-items-center"></div>'); 1282 1283 const dragHandle = $('<div class="handle me-2"><i class="fas fa-grip-vertical position-relative"></i></div>'); 1284 leftContent.append(dragHandle); 1285 1286 const todoCheckbox = $(`<input type="checkbox" class="printable pe-2" style="width: 16px; height: 16px; margin-top: 6px;" aria-labelledby="complete task" ${todo.Completed ? 'checked' : ''}>`); 1287 todoCheckbox.change(() => tickOffTodo(todo.Id)); 1288 1289 todoCheckbox.on('keyup', function (e) { 1290 if (e.key === 'Enter' || e.keyCode === 13) { 1291 todoCheckbox.click(); 1292 } 1293 }); 1294
1295 const checkboxContainer = $('<div class="checkbox-container"></div>'); 1296 checkboxContainer.append(todoCheckbox); 1297 leftContent.append(checkboxContainer); 1298 1299 const labelText = $('<span class="todoText printable ms-1"></span>').text(todo.text); 1300 if (todo.Completed) { 1301 labelText.addClass('strikethrough'); 1302 listItem.addClass('completed'); 1303 } 1304 labelText.click(() => tickOffTodo(todo.Id)); 1305 leftContent.append(labelText); 1306 todoContent.append(leftContent); 1307 1308 const rightContent = $('<div class="ms-1"></div>'); 1309 const dropdownMenu = $('<div class="dropdown ms-1"></div>'); 1310 const dropdownButton = $('<button class="btn my-1 btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-display="static" style="height: 32px; width: 34px;" aria-haspopup="true" aria-label="show task options" aria-expanded="false"></button>'); 1311 const dropdownIcon = $('<i class="fas fa-ellipsis-v"></i>'); 1312 dropdownButton.append(dropdownIcon); 1313 dropdownMenu.append(dropdownButton); 1314 1315 const buttonWrapper = $('<div class="d-inline-flex flex-column w-100 align-items-end align-content-end align-self-end justify-content-end" style="min-width: 70px;"></div>'); 1316 const innerWrapper = $(`<div class="d-inline-flex w-100 align-items-end align-content-end align-self-end justify-content-end" style="min-width: 70px;"></div>`) 1317 1318 const dropdownContent = $('<div class="dropdown-menu dropdown-menu-end" ></div>'); 1319 1320 const estimateButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-primary w-100"><i class="fas fa-clock"></i> ' + window.i18n['todo.estimate'] + '</button></div>') 1321 estimateButton.on('click', async function () { 1322 await estimateTodo(todo) 1323 debouncedSave(); // Use debounced save for UI estimate operation 1324 renderedTodos.delete(todo.Id); // Force re-render by removing from rendered tracking 1325 loadTodo(todo, true) // Force update 1326 }); 1327 1328 const clearEstimateButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-secondary w-100"><i class="fas fa-stop-circle"></i> ' + window.i18n['todo.clearEstimate'] + '</button></div>') 1329 clearEstimateButton.on('click', async function () { 1330 todo.Estimate = undefined 1331 debouncedSave(); // Use debounced save for UI estimate clearing 1332 renderedTodos.delete(todo.Id); // Force re-render by removing from rendered tracking 1333 loadTodo(todo, true) // Force update 1334 }); 1335 1336 const deadlineLabel = (todo.deadline 1337 ? (window.i18n['todo.deadline.edit'] || 'Edit deadline') 1338 : (window.i18n['todo.deadline.set'] || 'Add deadline')); 1339 const deadlineButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-primary w-100"><i class="far fa-calendar-alt"></i> ' + deadlineLabel + '</button></div>'); 1340 deadlineButton.on('click', function () { 1341 // Reveal the inline picker attached to the item and open its dropdown. 1342 // Deferred so the outer (three-dot) dropdown finishes closing first, 1343 // otherwise Bootstrap will immediately re-close the new dropdown. 1344 setTimeout(function () { 1345 const $btn = $(`#todo-${todo.Id}`).find('.deadline-box').first(); 1346 if ($btn.length === 0) return; 1347 $btn.show(); 1348 if (typeof bootstrap !== 'undefined') { 1349 bootstrap.Dropdown.getOrCreateInstance($btn[0]).show(); 1350 } else { 1351 $btn.trigger('click'); 1352 } 1353 }, 150); 1354 }); 1355 1356 const clearDeadlineButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-secondary w-100"><i class="fas fa-stop-circle"></i> ' + (window.i18n['todo.deadline.clear'] || 'Clear deadline') + '</button></div>'); 1357 clearDeadlineButton.on('click', function () { 1358 clearDeadlineForTodo(todo); 1359 }); 1360 1361 const editButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-info w-100"><i class="fas fa-edit"></i> ' + window.i18n['todo.edit'] + '</button></div>') 1362 editButton.on('click', function () { 1363 editTodo(todo.Id); 1364 });
1365 1366 1367 1368 const removeButton = $('<div class="dropdown-item"><button class="btn btn-sm btn-outline-danger w-100"><i class="fas fa-trash-alt"></i> ' + window.i18n['todo.remove'] + '</button></div>') 1369 removeButton.on('click', function () { 1370 removeTodo(todo); 1371 }); 1372 1373 //const subtaskList = getChildrenById(todo.Id); 1374 1375 const addButton = $('<div class="dropdown-item border-bottom"><button class="btn btn-sm btn-outline-success w-100"><i class="fas fa-plus"></i> ' + window.i18n['todo.addSubtask'] + '</button></div>') 1376 1377 addButton.on('click', function () { 1378 createNewSubtaskInput(todo.Id); 1379 }); 1380 1381 dropdownContent.append(estimateButton) 1382 dropdownContent.append(deadlineButton) 1383 dropdownContent.append(editButton) 1384 dropdownContent.append(addButton); 1385 if (todo.Estimate) { 1386 dropdownContent.append(clearEstimateButton) 1387 } 1388 if (todo.deadline) { 1389 dropdownContent.append(clearDeadlineButton) 1390 } 1391 1392 1393 if (hasChildren(todo.Id)) { 1394 const breakdownButton = $('<div class="dropdown-item "><button class="magic btn btn-sm btn-outline-primary w-100"><i class="fas fa-broom"></i> ' + window.i18n['todo.clearSubtasks'] + '</button></div>').click(() => clearChildren(todo)); 1395 dropdownContent.append(breakdownButton) 1396 const breakdownSpinnerContainer = $('<button style="display: none; height: 32px; width: 32px;" class="spinner magic btn btn-sm btn-outline-primary"></button>'); 1397 const breakdownSpinner = $('<span class="spinner-border spinner-border-sm" style="display: inline-block;""></span>') 1398 breakdownSpinnerContainer.append(breakdownSpinner) 1399 innerWrapper.append(breakdownSpinnerContainer); 1400 1401 if (TodoStore.getChildren(todo.Id).length >= 2) { 1402 const prioritizeBtn = $('<div class="dropdown-item"><button class="magic btn btn-sm btn-outline-primary w-100"><i class="fas fa-sort-amount-down"></i> ' + window.i18n['todo.prioritizeSubtasks'] + '</button></div>'); 1403 prioritizeBtn.on('click', async () => { await prioritizeSubtasks(todo); }); 1404 dropdownContent.append(prioritizeBtn); 1405 } 1406 1407 // Save this item and its breakdown as a reusable template (Pro). 1408 // Only offered where there's actually a breakdown to save â a bare 1409 // item makes a useless template. 1410 if (typeof saveAsTemplate === 'function') { 1411 const templateBtn = $('<div class="dropdown-item"><button class="magic btn btn-sm btn-outline-primary w-100"><i class="fas fa-layer-group"></i> ' + (window.i18n['todo.templates.saveAs'] || 'Save as template') + '</button></div>'); 1412 // Anchor the not-subscribed nudge to the three-dot toggle, not the 1413 // menu item: the menu closes on click, so the item goes display:none 1414 // while the toggle stays put and visible for the popover and for 1415 // returning focus when it closes. 1416 templateBtn.on('click', () => saveAsTemplate(todo.Id, dropdownButton[0])); 1417 dropdownContent.append(templateBtn); 1418 } 1419 } 1420 1421 dropdownContent.append(removeButton); 1422 1423 buttonWrapper.append(innerWrapper) 1424 1425 // Narrow-screen layout: on anything narrower than Bootstrap's lg 1426 // breakpoint (~992px â tablets, phones, even some small laptop 1427 // windows), the estimate + deadline pills go into their OWN row 1428 // above the action buttons instead of inline with them. The old 1429 // inline arrangement was pushing the three-dot and breakdown buttons 1430 // off the right edge whenever both a deadline AND an estimate were 1431 // set, and below ~992px even a single pill + the buttons leaves too 1432 // little width for a reasonable task title. Using matchMedia here 1433 // (rather than the app-only isMobile()) ensures mobile browsers and 1434 // iOS/Android apps all get the same stacked layout whenever they're 1435 // narrow enough to need it. 1436 const narrowLayout = window.matchMedia('(max-width: 991.98px)').matches; 1437 let chipRow = null; 1438 if (narrowLayout) { 1439 chipRow = $('<div class="todo-chip-row d-flex flex-wrap justify-content-end align-items-center"></div>'); 1440 // Prepend so pills sit ABOVE innerWrapper in the column-flex
1441 // buttonWrapper. innerWrapper was appended just above. 1442 buttonWrapper.prepend(chipRow); 1443 } 1444 // The "pill lane" â where the estimate and deadline chips live. Equal 1445 // to chipRow on narrow screens, innerWrapper on wide ones. 1446 const pillWrapper = narrowLayout ? chipRow : innerWrapper; 1447 1448 var estimateText = "" 1449 if (todo.Estimate && todo.Estimate > 0) { 1450 estimateText = convert(todo.Estimate, 2) 1451 // Abbreviate on either the mobile app shells OR narrow screens, so 1452 // the pill stays compact in both contexts. 1453 if (isMobile() || narrowLayout) { 1454 estimateText = estimateText 1455 .replace(window.i18n['time.minutes'], "min").replace(window.i18n['time.minute'], "min") 1456 .replace(window.i18n['time.seconds'], "sec").replace(window.i18n['time.second'], "sec") 1457 } 1458 1459 const estimateDiv = $(`<span class="dropdown mx-1 my-1" ></span>`) 1460 const estimateWrapper = pillWrapper; 1461 estimateWrapper.append(estimateDiv); 1462 1463 const estimate = $(`<button class="btn btn-sm mx-1 estimate border dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-display="static" aria-haspopup="true" aria-expanded="false"><span class="pe-2">${estimateText}</span></button>`) 1464 1465 estimateDiv.append(estimate); 1466 //estimate.on('click', function () { 1467 var durationcontainer = $(`<div class="dropdown-menu dropdown-menu-end p-2 m-0" style="width: 285px;"></div>`) 1468 var durationitem = $(`<div class="dropdown-item duration-item p-0 m-0"></div>`) 1469 var durationSelect = $(`<span class="border"></span>`) 1470 1471 durationSelect.durationjs({ initVal: todo.Estimate }); 1472
1473 const estimatebuttonContainer = $('<div class="d-inline-flex" style="width: 70px;"></div>'); 1474 const saveButton = $(`<button type="button" class="btn btn-sm btn-success edit-confirm" style="width: 32px; height: 32px;" data-tooltip="Save changes"><i class="fas fa-check"></i></button>`); 1475 const cancelButton = $(`<button type="button" class="btn btn-sm btn-danger edit-cancel" style="width: 32px; height: 32px;" data-tooltip="Cancel"><i class="fas fa-times"></i></button>`); 1476 estimatebuttonContainer.append(saveButton, cancelButton) 1477 durationitem.append(durationSelect, estimatebuttonContainer) 1478 durationcontainer.append(durationitem) 1479 // window.addVoiceRecognition(input); 1480 1481 durationSelect.on('keyup', function (event) { 1482 if (event.key === 'Enter') { 1483 saveButton.click(); 1484 } 1485 }); 1486 1487 saveButton.on('click', function () { 1488 var newEstimate = durationSelect[0].value 1489 todo.Estimate = newEstimate 1490 debouncedSave(); // Use debounced save for UI estimate changes 1491 renderedTodos.delete(todo.Id); // Force re-render by removing from rendered tracking 1492 loadTodo(todo, true) // Force update 1493 estimateWrapper.remove(durationcontainer) 1494 // estimatevalueelement = durationSelect.find('duration-val') 1495 }); 1496 1497 cancelButton.on('click', function () { 1498 durationSelect.durationjs({ initVal: todo.Estimate }); 1499 estimateWrapper.remove(durationcontainer) 1500 }); 1501 1502 1503 estimateDiv.append(durationcontainer); 1504 //}); 1505 1506 1507 1508 } 1509 1510 // Deadline control: always rendered so the "Add deadline" menu item can open 1511 // the same inline editor used for changing an existing deadline. The toggle 1512 // button is kept hidden when no deadline is set â only the menu reveals it. 1513 const deadlineWrapper = pillWrapper; 1514 const overdueClass = todo.deadline && deadlineIsOverdue(todo.deadline) ? ' overdue' : ''; 1515 const $deadlineContainer = $('<span class="dropdown deadline-container mx-1 my-1"></span>'); 1516 const $deadlineBtn = $('<button type="button" class="btn btn-sm mx-1 deadline-box border dropdown-toggle' + overdueClass + '" data-bs-toggle="dropdown" data-bs-display="static" aria-haspopup="true" aria-expanded="false"></button>') 1517 .attr('data-tooltip', window.i18n['todo.deadline.tooltip'] || 'Deadline') 1518 .attr('aria-label', window.i18n['todo.deadline.tooltip'] || 'Deadline'); 1519 $deadlineBtn.append('<i class="far fa-calendar-alt me-1"></i>'); 1520 $deadlineBtn.append($('<span class="deadline-text"></span>').text(todo.deadline ? formatDeadline(todo.deadline) : '')); 1521 if (!todo.deadline) $deadlineBtn.hide(); 1522 $deadlineContainer.append($deadlineBtn); 1523 1524 const $deadlineMenu = $('<div class="dropdown-menu dropdown-menu-end p-2 m-0"></div>'); 1525 const $deadlineRow = $('<div class="d-inline-flex align-items-center"></div>'); 1526 const $deadlineInput = $('<input type="datetime-local" class="form-control form-control-sm deadline-input" />'); 1527 const $deadlineSave = $('<button type="button" class="btn btn-sm btn-success edit-confirm ms-2" style="width: 32px; height: 32px;"><i class="fas fa-check"></i></button>'); 1528 const $deadlineCancel = $('<button type="button" class="btn btn-sm btn-danger edit-cancel ms-1" style="width: 32px; height: 32px;"><i class="fas fa-times"></i></button>'); 1529 $deadlineRow.append($deadlineInput, $deadlineSave, $deadlineCancel); 1530 $deadlineMenu.append($deadlineRow); 1531 $deadlineContainer.append($deadlineMenu); 1532 deadlineWrapper.append($deadlineContainer); 1533 1534 // Refresh the input with the current value whenever the picker opens (via 1535 // click on the box or the "Add deadline" menu item). 1536 $deadlineBtn.on('show.bs.dropdown', function () { 1537 $deadlineInput.val(todo.deadline || ''); 1538 setTimeout(() => $deadlineInput.trigger('focus'), 0); 1539 }); 1540 1541 // If the dropdown hides without a save (outside click / Escape / cancel) 1542 // and there's still no deadline, collapse the button back out of sight. 1543 $deadlineBtn.on('hidden.bs.dropdown', function () { 1544 if (!todo.deadline) $deadlineBtn.hide(); 1545 });
1546 1547 $deadlineInput.on('keydown', function (e) { 1548 if (e.key === 'Enter') { e.preventDefault(); $deadlineSave.trigger('click'); } 1549 }); 1550 1551 $deadlineSave.on('click', function () { 1552 const val = ($deadlineInput.val() || '').trim(); 1553 TodoStore.dispatch('UPDATE_TODO', { 1554 todoId: todo.Id || todo.id, 1555 // Send '' (not undefined) when clearing: the reducer skips undefined 1556 // fields, so emptying the input + Save would otherwise be a no-op. 1557 // '' passes the guard and the reducer normalizes it to undefined. 1558 updates: { deadline: val ? val : '' } 1559 }); 1560 // Flush immediately so a fast refresh doesn't lose the value before the 1561 // 300ms debounce would have fired, and so runSync() picks up the new 1562 // value in localStorage right away. 1563 save(); 1564 renderedTodos.delete(todo.Id); 1565 loadTodo(todo, true); 1566 if (typeof bootstrap !== 'undefined') { 1567 bootstrap.Dropdown.getOrCreateInstance($deadlineBtn[0]).hide(); 1568 } 1569 }); 1570 1571 $deadlineCancel.on('click', function () { 1572 $deadlineInput.val(todo.deadline || ''); 1573 if (typeof bootstrap !== 'undefined') { 1574 bootstrap.Dropdown.getOrCreateInstance($deadlineBtn[0]).hide(); 1575 } 1576 }); 1577 1578 if ((todo.ParentId == null || todo.ParentId == undefined) && todo.Category) { 1579 renderCategoryPicker(todo, innerWrapper); 1580 } 1581 1582 // Add the "break down" button if the todo has a parent and no children 1583 if (!todo.Completed && !hasChildren(todo.Id)) { 1584 const breakdownButton = $('<button class="magic btn my-1 btn-sm btn-outline-primary" style="height: 32px; width: 32px;" aria-label="break down this item" data-tooltip="Break down item"></button>').click(() => breakdownTodo(todo)); 1585 const breakdownIcon = $('<i class="fas fa-magic"></i>'); 1586 const breakdownSpinner = $('<span class="spinner spinner-border spinner-border-sm" style="display: none;"></span>'); 1587 breakdownButton.append(breakdownIcon).append(breakdownSpinner); 1588 innerWrapper.append(breakdownButton); 1589 } 1590 1591 //const estimateButton = $('<button class="magic btn btn-sm btn-outline-primary" style="height: 32px; width: 32px;" aria-label="break down this item" data-tooltip="Break down item"></button>').click(() => { 1592 // estimateTodo(todo) 1593 // loadTodo(todo) 1594 //}); 1595 1596 //const estimateIcon = $('<i class="fas fa-clock"></i>'); 1597 //const estimateSpinner = $('<span class="spinner spinner-border spinner-border-sm" style="display: none;"></span>'); 1598 //estimateButton.append(estimateIcon).append(estimateSpinner); 1599 //buttonWrapper.append(estimateButton); 1600 1601 if (todo.Completed) { 1602 const removeButton = $('<button style="height: 32px; width: 32px;" class="btn my-1 btn-sm btn-outline-danger" data-tooltip="Delete item"><i class="fas fa-trash-alt"></i></button>') 1603 removeButton.on('click', function () { 1604 removeTodo(todo); 1605 }); 1606 innerWrapper.append(removeButton); 1607 } 1608 1609 dropdownMenu.append(dropdownContent); 1610 innerWrapper.append(dropdownMenu); 1611 rightContent.append(buttonWrapper); 1612 todoContent.append(rightContent); 1613 listItem.append(todoContent); 1614 1615 if (subtaskList.length && hasChildren(todo.Id) && subtaskList.find('> li').length) { 1616 1617 listItem.append(subtaskList); 1618 chevron = subtaskList.find('> .collapse-btn') 1619 if (chevron.length) 1620 chevron.on('click', function () { 1621 toggleCollapse(todo.Id); 1622 }); 1623 } 1624 //else { 1625 // const newList = $('<ul class="list-unstyled mt-2 ms-3 " style="min-height: 1.5rem;" ></ul>') 1626 // listItem.append(newList); 1627 //} 1628 1629 if (!itemFound) { 1630 if (todo.ParentId === null || todo.ParentId === undefined) { 1631 // Final-gate tab check. The top-of-function guard should 1632 // have already returned, but if anything bypasses that 1633 // (a new caller, a forced render), refuse to inject a 1634 // top-level todo into the wrong tab's list right here. 1635 // This is the ONLY DOM insertion point for top-level items. 1636 var _activeTabIdFinal = (typeof TodoStore !== 'undefined' && TodoStore.getActiveTabId) 1637 ? TodoStore.getActiveTabId() 1638 : null; 1639 if (_activeTabIdFinal && todo.tabId && todo.tabId !== _activeTabIdFinal) { 1640 console.warn('[createTodoItem append-gate] refused to inject todo', 1641 todo.Id, 'tab=', todo.tabId, 'activeTab=', _activeTabIdFinal); 1642 return; 1643 } 1644 1645 if (!$('#todo-list').find('> ul').length) { 1646 $('#todo-list').append('<ul class="list-unstyled border border-1"></ul>'); 1647 } 1648 if (TodoStore.getTodos().indexOf(todo) > 0) { 1649 $('#todo-list').find('> ul').append(listItem); 1650 } else { 1651 1652 $('#todo-list').find('> ul').prepend(listItem); 1653 } 1654 } else { 1655 const parentListItem = $(`#todo-${todo.ParentId}`); 1656 1657 // Check if there's an active subtask input before creating a new list container 1658 const hasActiveSubtaskInput = $(`#add-subtask-group-${todo.ParentId}`).length > 0;
1659 const subtaskInputContent = hasActiveSubtaskInput ? $(`#add-subtask-group-${todo.ParentId}`).clone(true) : null; 1660 1661 if (!parentListItem.find('> ul').length && !hasActiveSubtaskInput) { 1662 const newList = $('<ul class="list-unstyled border border-1 mt-2 ms-3" style="min-height: 1.5rem;"></ul>'); 1663 parentListItem.append(newList); 1664 } else if (hasActiveSubtaskInput && !parentListItem.find('> ul').length) { 1665 // If there's an active subtask input but no list, create the list but preserve the input 1666 const newList = $('<ul class="list-unstyled border border-1 mt-2 ms-3" style="min-height: 1.5rem;"></ul>'); 1667 parentListItem.append(newList); 1668 parentListItem.find('> ul').append(subtaskInputContent); 1669 } 1670 if (!parentListItem.find('> ul > .collapse-btn').length) { 1671 const chevron = $(`<span class="collapse-btn position-absolute" style="left: 0.7rem;" ><i class="fas fa-chevron-down"></i></span>`) 1672 chevron.on('click', function () { 1673 toggleCollapse(todo.ParentId); 1674 }); 1675 parentListItem.find('> ul').append(chevron); 1676 } 1677 parentListItem.find('> ul').append(listItem); 1678 if (uiOptions.collapsed.indexOf(todo.ParentId) > -1) { 1679 listItem.hide(); 1680 showCollapse(todo.ParentId) 1681 } 1682 } 1683 } 1684} 1685 1686function Uncollapse(todoId) { 1687 uiOptions.collapsed = removeFromArray(uiOptions.collapsed, todoId) 1688 debouncedSave(); // Use debounced save for UI uncollapse operation 1689 showCollapse(todoId) 1690 1691 filterTodosByCategories() 1692} 1693 1694function toggleCollapse(todoId) { 1695 if (uiOptions.collapsed.indexOf(todoId) > -1) { 1696 uiOptions.collapsed = removeFromArray(uiOptions.collapsed, todoId) 1697 saveUIOptions(); 1698 } else { 1699 uiOptions.collapsed.push(todoId) 1700 debouncedSave(); // Use debounced save for UI collapse operation 1701 } 1702 showCollapse(todoId) 1703 1704 filterTodosByCategories() 1705} 1706 1707function showCollapse(todoId) { 1708 todo = getTodoById(todoId) 1709 if (!todo) return; 1710 const todoElement = $(`#todo-${todoId}`); 1711 const parentElement = todoElement.find('> ul').first() 1712 const childElements = parentElement.find("> li"); 1713 const collapseButton = todoElement.find(".collapse-btn > i"); 1714 //const childTexts = childElements.find(".todoText") 1715 if (parentElement && parentElement.length > 0) { 1716 if (uiOptions.collapsed.indexOf(todoId) == -1 && parentElement.hasClass('collapsed')) { 1717 parentElement.removeClass('collapsed') 1718 parentElement.attr('class').split(' ').forEach(function (cls) { 1719 if (cls.startsWith('progress-')) { 1720 parentElement.removeClass(cls); 1721 } 1722 }); 1723 1724 parentElement.removeClass('me-2') 1725 childElements.slideDown(); 1726 parentElement.removeClass('border') 1727 collapseButton.removeClass("fa-chevron-right").addClass("fa-chevron-down"); 1728 } else if (uiOptions.collapsed.indexOf(todoId) > -1 && !parentElement.hasClass('collapsed')) { 1729 var children = getChildrenById(todoId) 1730 let completedCount = children.filter(x => x.isCompleted || x.Completed).length; 1731 1732 let percent = children.length > 0 1733 ? Math.floor(10 * completedCount / children.length) * 10 1734 : 0; 1735 parentElement.addClass('collapsed') 1736 parentElement.addClass('progress-' + percent) 1737 parentElement.addClass('me-2') 1738 childElements.slideUp(); 1739 parentElement.addClass('border') 1740 collapseButton.removeClass("fa-chevron-down").addClass("fa-chevron-right"); 1741 } 1742 } 1743} 1744 1745// Recompute the progress-fill of an ALREADY-collapsed item's bar. showCollapse() 1746// only sets the percentage on the collapse transition (and is a no-op when the 1747// item is already collapsed), so ticking a subtask while its parent is collapsed 1748// would otherwise leave the bar frozen until the item is expanded and
1749// re-collapsed. Call this for collapsed ancestors after a completion toggle. 1750function updateCollapsedProgress(todoId) { 1751 if (uiOptions.collapsed.indexOf(todoId) === -1) return; 1752 const parentElement = $(`#todo-${todoId}`).find('> ul').first(); 1753 if (!parentElement || parentElement.length === 0) return; 1754 if (!parentElement.hasClass('collapsed')) return; 1755 1756 const children = getChildrenById(todoId); 1757 const completedCount = children.filter(x => x.isCompleted || x.Completed).length; 1758 const percent = children.length > 0 1759 ? Math.floor(10 * completedCount / children.length) * 10 1760 : 0; 1761 1762 (parentElement.attr('class') || '').split(' ').forEach(function (cls) { 1763 if (cls.startsWith('progress-')) parentElement.removeClass(cls); 1764 }); 1765 parentElement.addClass('progress-' + percent); 1766} 1767 1768function updateTodoOrder() { 1769 var todoElements = $("#todo-list").find('.list-group-item') 1770 // const todoElementIds = $("#todo-list").children('.list-group-item').attr('id'); 1771 // const parentId = parentElementId ? parentElementId.replace('todo-', '') : null; 1772 const orderedTodos = []; 1773 //console.log("updating todos " + JSON.stringify(todoElements)); 1774 todoElements.each(function () { 1775 var elementId = $(this).attr('id') 1776 if (elementId) { 1777 const id = elementId.replace('todo-', ''); 1778 //console.log("updating todos order of item " + id); 1779 const todo = getTodoById(id); 1780 if (todo) { 1781 orderedTodos.push(todo); 1782 } 1783 } 1784 }); 1785 1786 console.log("updateTodoOrder: reordering " + orderedTodos.length + " todos"); 1787 console.log("New order (first 5):", orderedTodos.slice(0, 5).map(t => t.text?.substring(0, 20))); 1788 TodoStore.dispatch('REORDER', { todos: orderedTodos }); // Allow undo for reorder 1789 // Save immediately rather than debounced. debouncedSave() captures no 1790 // snapshot â it reads the live store when it fires â so a sync-applied 1791 // REORDER landing inside the debounce window would make the drag's OWN save 1792 // persist the post-revert order. A drop is a deliberate, discrete action; 1793 // treat it like addTodo, which already bypasses the debounce. 1794 save(); 1795 1796 // Update undo button state 1797 if (typeof historyChanged === "function") { 1798 historyChanged(false); 1799 } 1800} 1801 1802/** 1803 * Sync DOM with TodoStore after a sync operation. 1804 * This function atomically updates the DOM to match the store: 1805 * - Reorders existing elements without destroying them 1806 * - Adds new items 1807 * - Removes deleted items 1808 * - Updates changed item content (skipping items being edited) 1809 */ 1810function syncDomWithStore() { 1811 // Scope to the active tab: only items in this tab (or descendants of them) are 1812 // rendered. Items in other tabs are treated as "not in store" so their DOM 1813 // elements get removed and no new elements are created for them. 1814 const activeTabId = TodoStore.getActiveTabId(); 1815 const allTodos = TodoStore.getTodos(); 1816 const storeTodos = activeTabId 1817 ? allTodos.filter(t => t.tabId === activeTabId) 1818 : allTodos; 1819 1820 // Build a map of parent -> children in store order 1821 const childrenByParent = new Map(); 1822 childrenByParent.set(null, []); // Root level 1823 1824 storeTodos.forEach(todo => { 1825 const parentId = todo.parentId ?? todo.ParentId ?? null; 1826 if (!childrenByParent.has(parentId)) { 1827 childrenByParent.set(parentId, []); 1828 } 1829 childrenByParent.get(parentId).push(todo); 1830 }); 1831 1832 // Get all todo IDs in store (scoped to active tab) 1833 const storeIds = new Set(storeTodos.map(t => t.id)); 1834 1835 // Remove DOM elements that no longer exist in store 1836 $('#todo-list').find('.list-group-item').each(function() { 1837 const id = $(this).attr('id')?.replace('todo-', ''); 1838 if (id && !storeIds.has(Number(id)) && !storeIds.has(id)) { 1839 $(this).remove(); 1840 delete todoElementCache[id]; 1841 renderedTodos.delete(id); 1842 renderedTodos.delete(Number(id)); 1843 } 1844 });
1845 1846 // Recursively sync children for a parent 1847 function syncChildren(parentId) { 1848 const children = childrenByParent.get(parentId) || []; 1849 if (children.length === 0) return; 1850 1851 // Get the container UL for this parent 1852 let container; 1853 if (parentId === null) { 1854 container = $('#todo-list').find('> ul'); 1855 if (container.length === 0) { 1856 $('#todo-list').append('<ul class="list-unstyled border border-1"></ul>'); 1857 container = $('#todo-list').find('> ul'); 1858 } 1859 } else { 1860 const parentElement = $(`#todo-${parentId}`); 1861 if (parentElement.length === 0) return; // Parent doesn't exist in DOM yet 1862 1863 container = parentElement.find('> ul'); 1864 // Create container if parent exists but has no ul for children 1865 if (container.length === 0) { 1866 const newList = $('<ul class="list-unstyled border border-1 mt-2 ms-3" style="min-height: 1.5rem;"></ul>'); 1867 parentElement.append(newList); 1868 container = parentElement.find('> ul'); 1869 1870 // Add collapse button 1871 const chevron = $(`<span class="collapse-btn position-absolute" style="left: 0.7rem;"><i class="fas fa-chevron-down"></i></span>`); 1872 chevron.on('click', function () { 1873 toggleCollapse(parentId); 1874 }); 1875 container.append(chevron); 1876 } 1877 } 1878 1879 if (container.length === 0) return; 1880 1881 // Get current DOM order for this container (only direct children) 1882 const currentDomIds = []; 1883 container.find('> .list-group-item').each(function() { 1884 const id = $(this).attr('id')?.replace('todo-', ''); 1885 if (id) currentDomIds.push(id); 1886 }); 1887 1888 // Get expected order from store 1889 const expectedIds = children.map(t => String(t.id)); 1890 1891 // Check if reorder is needed 1892 const needsReorder = currentDomIds.length !== expectedIds.length || 1893 !currentDomIds.every((id, i) => id === expectedIds[i]); 1894 1895 if (needsReorder) { 1896 // Reorder by moving elements - preserve their state (edits, etc.) 1897 children.forEach((todo, index) => { 1898 const todoId = todo.id; 1899 let element = $(`#todo-${todoId}`); 1900 1901 // Check if element is being edited - skip moving if so 1902 const isBeingEdited = element.find('.edit-input').length > 0; 1903 1904 if (element.length === 0) { 1905 // New item - create it 1906 createTodoItem(todo); 1907 element = $(`#todo-${todoId}`); 1908 } 1909 1910 if (element.length > 0 && !isBeingEdited) { 1911 // Move to correct position 1912 if (index === 0) { 1913 // Find the collapse button if it exists and insert after it 1914 const collapseBtn = container.find('> .collapse-btn'); 1915 if (collapseBtn.length > 0) { 1916 collapseBtn.after(element); 1917 } else { 1918 container.prepend(element); 1919 } 1920 } else { 1921 // Insert after the previous sibling 1922 const prevTodo = children[index - 1]; 1923 const prevElement = $(`#todo-${prevTodo.id}`); 1924 if (prevElement.length > 0) { 1925 prevElement.after(element); 1926 } 1927 } 1928 } 1929 }); 1930 } 1931 1932 // Update content of changed items (skip items being edited) 1933 children.forEach(todo => { 1934 const element = $(`#todo-${todo.id}`); 1935 if (element.length > 0) { 1936 const isBeingEdited = element.find('.edit-input').length > 0; 1937 if (!isBeingEdited) { 1938 // Update text if changed 1939 const textElement = element.find(`#todo-text-${todo.id} .todoText`); 1940 if (textElement.length > 0 && textElement.text() !== todo.text) { 1941 textElement.text(todo.text); 1942 } 1943 1944 // Update completed state if changed 1945 const checkbox = element.find('> div > div > div > input[type="checkbox"]'); 1946 const isCompleted = todo.isCompleted || todo.Completed; 1947 if (checkbox.prop('checked') !== isCompleted) { 1948 checkbox.prop('checked', isCompleted); 1949 const label = element.find('.todoText'); 1950 if (isCompleted) { 1951 label.addClass('strikethrough'); 1952 element.addClass('completed'); 1953 } else { 1954 label.removeClass('strikethrough'); 1955 element.removeClass('completed'); 1956 } 1957 } 1958 } 1959 1960 // Recursively sync this item's children 1961 syncChildren(todo.id); 1962 } 1963 }); 1964 } 1965 1966 // Start syncing from root 1967 syncChildren(null); 1968 1969 // Re-enable draggable after sync 1970 debouncedEnableDraggable(); 1971} 1972 1973function enableDraggable() { 1974 console.time('enableDraggable'); 1975 1976 // First check and destroy any existing sortables to prevent memory leaks 1977 try { 1978 // Only target elements that actually have the ui-sortable class 1979 const existingSortables = $('.list-group ul.ui-sortable'); 1980 if (existingSortables.length > 0) { 1981 existingSortables.sortable('destroy'); 1982 } 1983 } catch (e) { 1984 //console.log("Error destroying sortables (can be ignored if first run):", e.message); 1985 } 1986 1987 // Then re-initialize sortables 1988 $('.list-group ul').sortable({ 1989 connectWith: 'ul', 1990 items: '> .list-group-item', 1991 handle: '.handle', 1992 revert: 100, 1993 //tolerance: 'pointer', 1994 helper: 'clone', 1995 classes: { 1996 "ui-sortable": "highlight", 1997 }, 1998 start: function (event, ui) { 1999 if (typeof TodoOperations !== "undefined" && 2000 TodoOperations.getRunningOperations && 2001 TodoOperations.getRunningOperations(null).includes('prioritize')) { 2002 return false; 2003 } 2004 ui.item.addClass('dragging'); 2005 }, 2006 stop: function (event, ui) { 2007 console.log("dropped") 2008 ui.item.removeClass('dragging'); 2009 updateTodoOrder(event, ui); 2010 //loadTodo() 2011 }, 2012 receive: function (event, ui) { 2013 2014 console.log("received") 2015 // Update the parentId of the received item 2016 const parentId = $(event.target).parent().attr('id') ? $(event.target).parent().attr('id').replace('todo-', '') : null; 2017 const itemId = ui.item.attr('id').replace('todo-', ''); 2018 if (itemId != parentId) { 2019 var todo = getTodoById(itemId) 2020 if (todo && parentId == 'list') { 2021 todo.ParentId = null; 2022 categoriseTodo(getTodoById(itemId)); 2023 } 2024 else if (todo) { 2025 // Reject circular parenting: dropping a todo onto one of its 2026 // own descendants would orphan the dragged subtree. 2027 var descendants = (typeof TodoStore !== "undefined" && TodoStore.getDescendants) 2028 ? TodoStore.getDescendants(itemId) 2029 : [];
2030 var wouldCycle = descendants.some(function (d) { 2031 return String(d.id) === String(parentId); 2032 }); 2033 if (wouldCycle) { 2034 console.warn("Rejected drag: would create a circular parent for todo " + itemId); 2035 if (typeof warning === "function") { 2036 warning("Can't move a task under one of its own subtasks."); 2037 } 2038 ui.sender.sortable('cancel'); 2039 return; 2040 } 2041 todo.ParentId = parentId 2042 } 2043 save() // Immediate: a drop is discrete, and a debounced save 2044 // would read the store after any sync REORDER that lands 2045 // in the window (see updateTodoOrder). 2046 loadTodo(todo) 2047 } 2048 }, 2049 }); 2050 2051 draggableInitialized = true; 2052 draggablePending = false; 2053 console.timeEnd('enableDraggable'); 2054} 2055 2056function editTodo(todoId) { 2057 // Check if there's already an edit operation in progress for this todo 2058 if (TodoOperations.isRunning('edit', todoId)) { 2059 console.log('Edit already in progress for this todo'); 2060 return; 2061 } 2062 2063 const todoElement = $(`#todo-${todoId} > div > div:first-child > span`); 2064 2065 const todoParent = $(`#todo-text-${todoId} > div`); 2066 const currentText = todoElement.text(); 2067 2068 const inputDiv = $('<div class="edit-input-container d-flex"></div>') 2069 const input = $(`<input data-voice type="text" class="form-control edit-input" value="${currentText}">`); 2070 2071 2072 const buttonWrapper = $('<div class="d-inline-flex pt-1" style="width: 70px;"></div>'); 2073 const saveButton = $(`<button type="button" class="btn btn-sm btn-success edit-confirm" style="width: 32px; height: 32px;" data-tooltip="Save changes"><i class="fas fa-check"></i></button>`); 2074 const cancelButton = $(`<button type="button" class="btn btn-sm btn-danger edit-cancel" style="width: 32px; height: 32px;" data-tooltip="Cancel"><i class="fas fa-times"></i></button>`); 2075 buttonWrapper.append(saveButton, cancelButton) 2076 inputDiv.append(input, buttonWrapper); 2077 2078 // window.addVoiceRecognition(input); 2079 2080 input.on('keyup', function (event) { 2081 if (event.key === 'Enter') { 2082 saveButton.click(); 2083 } 2084 }); 2085 2086 saveButton.on('click', function () { 2087 const saveOperation = async () => { 2088 const newText = input.val().trim(); 2089 if (newText !== "") { 2090 todoElement.text(newText); 2091 const todo = getTodoById(todoId) 2092 todo.text = newText 2093 2094 // Update TodoStore 2095 TodoStore.dispatch('UPDATE_TODO', { 2096 todoId: todoId, 2097 updates: { Text: newText } 2098 }); 2099 2100 debouncedSave() // Use debounced save for UI operation 2101 removeEditControls(); 2102 } 2103 2104 if (window.clearTranscript) 2105 window.clearTranscript() 2106 }; 2107 2108 TodoOperations.enqueue('edit', todoId, saveOperation); 2109 }); 2110 2111 cancelButton.on('click', function () { 2112 2113 if (window.clearTranscript) 2114 window.clearTranscript() 2115 removeEditControls(); 2116 }); 2117 2118 todoElement.hide(); 2119 todoParent.hide(); 2120 todoParent.parent().append(inputDiv) 2121 2122 input.focus(); 2123 2124 function removeEditControls() { 2125 inputDiv.remove(); 2126 2127 todoElement.show(); 2128 todoParent.show(); 2129 } 2130} 2131 2132 2133 2134async function removeTodo(todo) { 2135 // Clear from caches 2136 if (todoElementCache[todo.Id]) { 2137 delete todoElementCache[todo.Id]; 2138 } 2139 renderedTodos.delete(todo.Id); 2140 2141 $(`#todo-${todo.Id}`).remove() 2142 await deleteTodo(todo); 2143 //loadTodo(todo.ParentId) 2144} 2145 2146function tickOffTodo(todoId) { 2147 const todoElement = getTodoElement(todoId); 2148 const todoCheckbox = todoElement.find('> div > div > div > input[type=checkbox]'); 2149 const todoTextElement = todoCheckbox.parent().parent().find('span'); 2150 2151 const todo = getTodoById(todoId); 2152 if (!todo) return; 2153 2154 if (todoCheckbox.prop('checked')) { 2155 todoTextElement.addClass('strikethrough'); 2156 todoElement.addClass('completed'); 2157 todo.Completed = true; 2158 } else { 2159 todoTextElement.removeClass('strikethrough'); 2160 todoElement.removeClass('completed'); 2161 todo.Completed = false; 2162 } 2163 2164 // Update TodoStore 2165 TodoStore.dispatch('UPDATE_TODO', { 2166 todoId: todoId, 2167 updates: { IsCompleted: todo.Completed }, 2168 skipHistory: true 2169 });
2170 2171 if (todo.hasAParent()) { 2172 var parent = getTodoById(todo.ParentId) 2173 if (parent) { 2174 var siblings = getChildrenById(parent.Id) 2175 if (siblings.every(x => x.Completed)) { 2176 parent.Completed = true; 2177 2178 // Update parent in TodoStore 2179 TodoStore.dispatch('UPDATE_TODO', { 2180 todoId: parent.Id, 2181 updates: { IsCompleted: true }, 2182 skipHistory: true 2183 }); 2184 2185 loadTodo(parent, true) // Force update to show completed state 2186 } 2187 } 2188 2189 } 2190 2191 // Refresh the frozen progress bar of any collapsed ancestor so ticking a 2192 // subtask while its parent is collapsed moves the bar right away instead of 2193 // waiting for an expand/re-collapse. Walk up from the ticked item; the 2194 // completion cascade above may also have flipped an ancestor, so each level 2195 // is recomputed from the current store state. 2196 var ancestor = todo; 2197 while (ancestor && ancestor.hasAParent && ancestor.hasAParent()) { 2198 var parentId = ancestor.ParentId; 2199 updateCollapsedProgress(parentId); 2200 ancestor = getTodoById(parentId); 2201 } 2202 2203 // Skip adding to history when checking/unchecking (per user request) 2204 save(true); // Pass true to skip history 2205 2206 // Update the UI after saving 2207 loadTodo(todo, true) // Force update to show checked/unchecked state 2208 filterTodosByCategories(); 2209 2210 // Make sure history buttons reflect new state 2211 if (typeof historyChanged === "function") { 2212 historyChanged(false); 2213 } 2214} 2215 2216function generateCategoryCheckboxes() { 2217 const categoryEmojis = [...new Set(getTopLevel().map(todo => todo.Category).filter(e => e))]; 2218 2219 const categoryFilterMenu = $("#categoryFilterMenu"); 2220 categoryFilterMenu.empty(); 2221 2222 if (categoryEmojis.length > 0) 2223 $("#categoryFilter").show() 2224 else 2225 $("#categoryFilter").hide() 2226 2227 var completeCheckbox = $(` 2228 <div class="form-check dropdown-item border-bottom"> 2229 <input class="form-check-input checkbox-complete" type="checkbox" id="completefilter" ` + ((uiOptions.showCompleted ?? true) ? `checked = "checked"` : ``) + `></input> 2230 <label class="form-check-label" for="completefilter">âï¸</label> 2231 </div > 2232 `) 2233 2234 categoryFilterMenu.append(completeCheckbox); 2235 2236 2237 categoryEmojis.forEach(emoji => { 2238 const menuItem = $(`<div class="form-check dropdown-item" ></div>`); 2239 const checkbox = $(`<input class="form-check-input checkbox-category" type="checkbox" value="${emoji}" id="category-${emoji}" ` + (uiOptions.filters.indexOf(emoji) > -1 ? `checked = "checked"` : ``) + `></input>`); 2240 const label = $(`<label class="form-check-label" for= "category-${emoji}" > ${emoji}</label>`); 2241 2242 menuItem.append(checkbox, label); 2243 categoryFilterMenu.append(menuItem); 2244 }); 2245} 2246 2247function filterTodosByCategories() { 2248 const selectedCategories = []; 2249 2250 $("#categoryFilterMenu .checkbox-category:checked").each(function () { 2251 selectedCategories.push($(this).val()); 2252 }); 2253 2254 uiOptions.filters = selectedCategories; 2255 2256 var showComplete = $("#categoryFilterMenu #completefilter").prop('checked') ?? true 2257 uiOptions.showCompleted = showComplete; 2258 debouncedSave(true); // Use debounced save for UI filter changes 2259 TodoStore.getTodos().forEach(todo => { 2260 const todoElement = $(`#todo-${todo.Id} `); 2261 var shouldHide = false; 2262 if (todo.ParentId) { 2263 const parentElement = $(`#todo-${todo.ParentId} > ul`); 2264 shouldHide = parentElement.hasClass('collapsed') 2265 } 2266 if (!showComplete && todo.Completed) { 2267 shouldHide = true; 2268 } 2269 else if (todo.ParentId == null || todo.ParentId == undefined) { 2270 if (selectedCategories.length > 0 && !selectedCategories.includes(todo.Category)) { 2271 shouldHide = true; 2272 2273 } 2274 } 2275 if (shouldHide) { 2276 todoElement.hide(); 2277 } 2278 else { 2279 todoElement.show(); 2280 } 2281 });
2282} 2283 2284function historyChanged(needReload = false) { 2285 2286 if (canUndo()) { 2287 $("#undo").removeAttr("disabled"); 2288 } else { 2289 $("#undo").attr("disabled", true); 2290 } 2291 if (canRedo()) { 2292 $("#redo").removeAttr("disabled"); 2293 } else { 2294 $("#redo").attr("disabled", true); 2295 } 2296 if (needReload) 2297 loadTodo() 2298 2299} 2300 2301 2302 2303 2304// Variables to track refresh state 2305let refreshPending = false; 2306let lastEditTime = Date.now(); 2307const REFRESH_DELAY = 10000; // 10 seconds delay 2308const MAX_REFRESH_ATTEMPTS = 6; // Try up to 6 times (60 seconds total) 2309 2310// Function to schedule a delayed refresh 2311function scheduleDelayedRefresh(attemptCount = 0) { 2312 // Mark that a refresh is pending 2313 refreshPending = true; 2314 2315 setTimeout(() => { 2316 // Check if any edits have happened in the last few seconds 2317 const timeSinceLastEdit = Date.now() - lastEditTime; 2318 2319 if (timeSinceLastEdit < 3000) { 2320 // Recent edit detected, reschedule if we haven't tried too many times 2321 if (attemptCount < MAX_REFRESH_ATTEMPTS) { 2322 console.log(`Rescheduling refresh (attempt ${attemptCount + 1} of ${MAX_REFRESH_ATTEMPTS})`); 2323 scheduleDelayedRefresh(attemptCount + 1); 2324 } else { 2325 console.log("Max refresh attempts reached, forcing refresh"); 2326 normaliseTodoOrder(); 2327 refreshPending = false; 2328 } 2329 } else { 2330 // No recent edits, safe to refresh 2331 console.log("Performing delayed refresh"); 2332 normaliseTodoOrder(); 2333 refreshPending = false; 2334 } 2335 }, REFRESH_DELAY); 2336} 2337 2338// Function to mark that an edit has occurred - exposed globally for todo-base.js 2339function markEditOccurred() { 2340 lastEditTime = Date.now(); 2341 2342 // Schedule a refresh if one isn't already pending 2343 if (!refreshPending) { 2344 scheduleDelayedRefresh(); 2345 } 2346} 2347 2348// Expose key functions globally for todo-base.js to access. 2349// 2350// `refreshPending` is deliberately NOT published. It is a boolean, so the old 2351// `window.refreshPending = refreshPending` copied its value once at load and 2352// then never changed â the guard in normaliseTodoOrder that read it was dead 2353// from the day it was written. See the comment there for why publishing it 2354// live would be worse, not better. 2355window.markEditOccurred = markEditOccurred; 2356 2357// Set up periodic check to normalize todo order, but less frequently 2358setInterval(() => { 2359 if (!refreshPending) { 2360 scheduleDelayedRefresh(); 2361 } 2362}, 60000);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.