PageSourceSearch

https://goblin.tools/js/todo.js?v=YzPxewyWiIn3iuLxBB3713e2ZOQbeisVGLiJJ3PgE9o

js goblin.tools collected 2026-10-01 11:37:09 UTC 98,942 bytes, 2,362 lines download raw bytes

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.