From e133471d810e1bdddac64fc2367537d945d3cc6f Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Tue, 23 Jun 2026 13:31:28 -0400 Subject: [PATCH 1/5] feat: paginate History tab with cursor-based IndexedDB pagination History tab now loads 50 pomodoros at a time using an IndexedDB cursor on the start_time index instead of dumping all records into the DOM. A "Load More" button appends the next page with correct date-group continuity across page boundaries. Closes #100 Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/storage.js | 33 ++++++++++ templates/index.html | 140 ++++++++++++++++++++++++++----------------- 2 files changed, 118 insertions(+), 55 deletions(-) diff --git a/static/js/storage.js b/static/js/storage.js index 6987eff..d5f11b0 100644 --- a/static/js/storage.js +++ b/static/js/storage.js @@ -1009,6 +1009,39 @@ return pomodoros; }, + /** + * Fetch a page of pomodoros using cursor-based pagination on the start_time index. + * Returns newest first. Use beforeTimestamp to fetch the next page. + * @param {number} limit - Number of records to fetch + * @param {string} [beforeTimestamp] - ISO timestamp to start before (exclusive) + * @returns {Promise<{pomodoros: Array, hasMore: boolean}>} + */ + getPomodorosPage: function(limit, beforeTimestamp) { + return new Promise((resolve, reject) => { + if (!db) { reject(new Error('Database not initialized')); return; } + const tx = db.transaction(STORES.POMODOROS, 'readonly'); + const store = tx.objectStore(STORES.POMODOROS); + const index = store.index('start_time'); + + const range = beforeTimestamp + ? IDBKeyRange.upperBound(beforeTimestamp, true) + : null; + const request = index.openCursor(range, 'prev'); + const results = []; + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor && results.length < limit) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve({ pomodoros: results, hasMore: !!cursor }); + } + }; + request.onerror = () => reject(request.error); + }); + }, + /** * Create a new pomodoro (timer completion) * @param {object} data - Pomodoro data (name, type, duration_minutes, notes) diff --git a/templates/index.html b/templates/index.html index 8716474..7b440db 100644 --- a/templates/index.html +++ b/templates/index.html @@ -693,6 +693,7 @@

History

+ @@ -2833,80 +2834,109 @@

Edit Pomodoro

}); }); - // History - async function loadHistory() { + // History — paginated + const HISTORY_PAGE_SIZE = 50; + let historyState = { lastTimestamp: null, hasMore: false, todoMap: null, lastDateGroup: null }; + + document.getElementById('history-load-more').addEventListener('click', () => loadHistory(false)); + + async function loadHistory(reset = true) { const list = document.getElementById('history-list'); + const loadMoreBtn = document.getElementById('history-load-more'); const tz = getEffectiveTimezone(); + try { - const pomodoros = await Storage.getPomodoros(); + if (reset) { + historyState = { lastTimestamp: null, hasMore: false, todoMap: null, lastDateGroup: null }; + list.innerHTML = ''; + } + + // Build todo map once per reset + if (!historyState.todoMap) { + historyState.todoMap = {}; + const allTodos = await Storage.getTodos(); + const allLists = await Storage.getTodoLists(); + for (const t of allTodos) { + const listName = allLists.find(l => l.id === t.list_id)?.name; + historyState.todoMap[t.id] = listName ? `${listName} > ${t.title}` : t.title; + } + } + + const { pomodoros, hasMore } = await Storage.getPomodorosPage(HISTORY_PAGE_SIZE, historyState.lastTimestamp); + historyState.hasMore = hasMore; - if (!Array.isArray(pomodoros) || pomodoros.length === 0) { + if (reset && pomodoros.length === 0) { list.innerHTML = '
No pomodoros yet. Start your first timer!
'; + loadMoreBtn.style.display = 'none'; return; } - // Build todo title map for linked pomodoros - const historyTodoMap = {}; - const allTodos = await Storage.getTodos(); - const allLists = await Storage.getTodoLists(); - for (const t of allTodos) { - const listName = allLists.find(l => l.id === t.list_id)?.name; - historyTodoMap[t.id] = listName ? `${listName} > ${t.title}` : t.title; + if (pomodoros.length > 0) { + historyState.lastTimestamp = pomodoros[pomodoros.length - 1].start_time; } - // Group by date (using configured timezone) + // Group this page by date const grouped = {}; + const groupOrder = []; pomodoros.forEach(p => { const date = formatDateWithDayTz(p.start_time, tz); - if (!grouped[date]) grouped[date] = []; + if (!grouped[date]) { grouped[date] = []; groupOrder.push(date); } grouped[date].push(p); }); - let html = ''; - for (const [date, items] of Object.entries(grouped)) { - // Sort items within this day by start time (morning to evening) + // Render and append + const fragment = document.createDocumentFragment(); + for (const date of groupOrder) { + const items = grouped[date]; + // Within each day, sort morning to evening for display items.sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - html += `

${date}

`; + + // If this date matches the last group already on screen, don't add a new header + if (date !== historyState.lastDateGroup) { + const h3 = document.createElement('h3'); + h3.textContent = date; + fragment.appendChild(h3); + } + historyState.lastDateGroup = date; + items.forEach(p => { const color = TYPE_COLORS[p.type] || '#6b7280'; const time = new Date(p.start_time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: tz }); const safeId = escapeHtml(p.id); - const linkedInfo = p.linked_todo_id && historyTodoMap[p.linked_todo_id] - ? ` · ${escapeHtml(historyTodoMap[p.linked_todo_id])}` : ''; - html += ` -
-
- ${escapeHtml(p.name)} - ${escapeHtml(p.type)} -
${time} - ${p.duration_minutes} min${linkedInfo}
-
-
- -
+ const linkedInfo = p.linked_todo_id && historyState.todoMap[p.linked_todo_id] + ? ` · ${escapeHtml(historyState.todoMap[p.linked_todo_id])}` : ''; + + const div = document.createElement('div'); + div.className = 'pomodoro-item'; + div.dataset.id = safeId; + div.innerHTML = ` +
+ ${escapeHtml(p.name)} + ${escapeHtml(p.type)} +
${time} - ${p.duration_minutes} min${linkedInfo}
+
+
+
`; + div.addEventListener('click', () => { + document.querySelectorAll('.pomodoro-item.highlighted').forEach(el => el.classList.remove('highlighted')); + div.classList.add('highlighted'); + showEditModal(safeId); + }); + div.querySelector('.delete-btn').addEventListener('click', (e) => { + e.stopPropagation(); + deletePomodoro(safeId); + }); + fragment.appendChild(div); }); } - list.innerHTML = html; - - // Attach event listeners - click item to edit, delete button to delete - list.querySelectorAll('.pomodoro-item').forEach(item => { - const id = item.dataset.id; - // Click anywhere on item to edit (with highlight) - item.addEventListener('click', () => { - document.querySelectorAll('.pomodoro-item.highlighted').forEach(el => el.classList.remove('highlighted')); - item.classList.add('highlighted'); - showEditModal(id); - }); - // Delete button stops propagation so it doesn't trigger edit - item.querySelector('.delete-btn').addEventListener('click', (e) => { - e.stopPropagation(); - deletePomodoro(id); - }); - }); + list.appendChild(fragment); + + loadMoreBtn.style.display = hasMore ? '' : 'none'; } catch (e) { console.error('Error loading history:', e); - list.innerHTML = '
Error loading history. Please refresh.
'; + if (reset) list.innerHTML = '
Error loading history. Please refresh.
'; } } @@ -2925,19 +2955,19 @@

Edit Pomodoro

histBtn.setAttribute('tabindex', '0'); document.getElementById('history-view').classList.add('active'); - // Load history and wait for it to complete + // Load first page and search for the target pomodoro await loadHistory(); - // Find and highlight the pomodoro - const item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); + let item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); + // If not on current page, keep loading until found or exhausted + while (!item && historyState.hasMore) { + await loadHistory(false); + item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); + } + if (item) { - // Remove any existing highlights document.querySelectorAll('.pomodoro-item.highlighted').forEach(el => el.classList.remove('highlighted')); - - // Scroll to the item item.scrollIntoView({ behavior: 'smooth', block: 'center' }); - - // Add highlight (stays until another item is clicked) item.classList.add('highlighted'); } } From 2cb31d3d5a1ec468f1741151ed1a7167b91d1063 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Tue, 23 Jun 2026 13:33:23 -0400 Subject: [PATCH 2/5] fix: cap navigateToPomodoro page-loading loop to prevent runaway fetching Gatehouse flagged unbounded while-loop that loads pages until the target pomodoro is found. Cap at 10 pages (500 items) to prevent browser resource exhaustion when targeting very old pomodoros. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/index.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/index.html b/templates/index.html index 7b440db..56cc1ca 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2959,10 +2959,12 @@

Edit Pomodoro

await loadHistory(); let item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); - // If not on current page, keep loading until found or exhausted - while (!item && historyState.hasMore) { + // Load up to 10 more pages (500 items) to find the target — cap prevents runaway loading + let pagesLoaded = 0; + while (!item && historyState.hasMore && pagesLoaded < 10) { await loadHistory(false); item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); + pagesLoaded++; } if (item) { From fbf6faaa83b57ac04e8fefa240eb77002986d582 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Tue, 23 Jun 2026 14:33:48 -0400 Subject: [PATCH 3/5] perf: use IndexedDB start_time index for range queries and defer history load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that eliminate full-table-scan bottlenecks on boot: 1. getPomodoros(start, end) now uses IDBKeyRange on the start_time index instead of loading all records then filtering in JS. getReport() also uses the same indexed range query. 2. Removed loadHistory() from the boot sequence — the History tab isn't visible on load, so defer to the tab-click handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/storage.js | 24 +++++++++++++++++++----- templates/index.html | 1 - 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/static/js/storage.js b/static/js/storage.js index d5f11b0..851ee67 100644 --- a/static/js/storage.js +++ b/static/js/storage.js @@ -194,6 +194,20 @@ return dbTransaction(storeName, 'readonly', (store) => store.getAll()); } + /** + * Get records from a store index within a key range + */ + function getRangeFromIndex(storeName, indexName, lower, upper) { + return dbTransaction(storeName, 'readonly', (store) => { + const index = store.index(indexName); + let range = null; + if (lower && upper) range = IDBKeyRange.bound(lower, upper); + else if (lower) range = IDBKeyRange.lowerBound(lower); + else if (upper) range = IDBKeyRange.upperBound(upper); + return index.getAll(range); + }); + } + /** * Get a single record by key */ @@ -998,10 +1012,11 @@ * @returns {Promise} */ getPomodoros: async function(startDate, endDate) { - let pomodoros = await getAllFromStore(STORES.POMODOROS); - + let pomodoros; if (startDate || endDate) { - pomodoros = filterByDateRange(pomodoros, startDate, endDate); + pomodoros = await getRangeFromIndex(STORES.POMODOROS, 'start_time', startDate || null, endDate || null); + } else { + pomodoros = await getAllFromStore(STORES.POMODOROS); } // Sort by start_time descending (most recent first) @@ -1216,8 +1231,7 @@ endIso = range.end.toISOString(); } - const allPomodoros = await getAllFromStore(STORES.POMODOROS); - const pomodoros = filterByDateRange(allPomodoros, startIso, endIso); + const pomodoros = await getRangeFromIndex(STORES.POMODOROS, 'start_time', startIso, endIso); const stats = calculateReportStats(pomodoros, dates); return { diff --git a/templates/index.html b/templates/index.html index 56cc1ca..8be11d1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5775,7 +5775,6 @@

Edit Pomodoro

(async () => { await checkAuthStatus(); await loadWeeklyOverview(); - await loadHistory(); // Load todos from cloud if logged in, then render if (authStatus.logged_in) { From c29aa1c6020db9b68206de5b9da4a2dc3e054038 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Tue, 23 Jun 2026 14:39:03 -0400 Subject: [PATCH 4/5] perf: parallelize boot sequence and remove dead code Boot now runs in three phases: 1. Open IndexedDB + load settings (no network, sub-ms) 2. Auth check, weekly overview, and todos in parallel via Promise.all 3. Cloud sync after auth resolves Also exposes Storage.openDatabase() with idempotent guard, removes the redundant loadSettings() call from checkAuthStatus(), and deletes the dead filterByDateRange() function (replaced by getRangeFromIndex). Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/storage.js | 21 +++++++++------------ templates/index.html | 19 +++++++++++++------ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/static/js/storage.js b/static/js/storage.js index 851ee67..2ae2ae3 100644 --- a/static/js/storage.js +++ b/static/js/storage.js @@ -100,6 +100,7 @@ * Open IndexedDB database */ function openDatabase() { + if (db) return Promise.resolve(db); return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); @@ -359,17 +360,6 @@ })); } - /** - * Filter pomodoros by date range - */ - function filterByDateRange(pomodoros, startDate, endDate) { - return pomodoros.filter(p => { - if (startDate && p.start_time < startDate) return false; - if (endDate && p.start_time > endDate) return false; - return true; - }); - } - /** * Calculate report statistics */ @@ -666,12 +656,19 @@ * Storage API */ const Storage = { + /** + * Open IndexedDB without running auth/network initialization. + * Call this early so IndexedDB reads (settings, pomodoros) work + * before the full init() completes. + */ + openDatabase: openDatabase, + /** * Initialize storage based on auth status * @param {object} status - Auth status from /api/auth/status */ init: async function(status) { - // Open IndexedDB first + // Open IndexedDB first (idempotent if already opened) await openDatabase(); // Load credentials from AUTH store (ephemeral - OAuth tokens only) diff --git a/templates/index.html b/templates/index.html index 8be11d1..f663c6d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4278,8 +4278,6 @@

Edit Pomodoro

updateGooglePomodoroCount(); } - // Always load settings and plugins (independent of auth) - await loadSettings(); } // Update storage mode indicator in header @@ -5773,14 +5771,23 @@

Edit Pomodoro

} (async () => { - await checkAuthStatus(); - await loadWeeklyOverview(); + // Phase 1: Open IndexedDB and load settings (no network, sub-ms). + // This lets the UI render with correct timezone/date format immediately. + await Storage.openDatabase(); + await loadSettings(); + + // Phase 2: Run auth (network) and view rendering (IndexedDB) in parallel. + await Promise.all([ + checkAuthStatus(), + loadWeeklyOverview(), + loadTodos() + ]); - // Load todos from cloud if logged in, then render + // Phase 3: Cloud sync needs auth, so runs after auth resolves. if (authStatus.logged_in) { await Storage.loadTodosFromCloud(); + await loadTodos(); } - await loadTodos(); })(); From 784503b6fa708e54cf6274ce7f99e5e12b0005a4 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Tue, 23 Jun 2026 14:40:14 -0400 Subject: [PATCH 5/5] fix: remove redundant loadTodos() call from boot parallel phase Gatehouse flagged that loadTodos() was called twice for logged-in users: once in Promise.all (stale local data) and again after cloud sync (fresh data). Move it after cloud sync so todos render once with the most current state. Todo tab isn't visible on boot so no UX impact. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/index.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/templates/index.html b/templates/index.html index f663c6d..0c39173 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5779,15 +5779,14 @@

Edit Pomodoro

// Phase 2: Run auth (network) and view rendering (IndexedDB) in parallel. await Promise.all([ checkAuthStatus(), - loadWeeklyOverview(), - loadTodos() + loadWeeklyOverview() ]); - // Phase 3: Cloud sync needs auth, so runs after auth resolves. + // Phase 3: Sync cloud data then render todos once with fresh state. if (authStatus.logged_in) { await Storage.loadTodosFromCloud(); - await loadTodos(); } + await loadTodos(); })();