feat: paginate History tab to prevent resource exhaustion#104
Conversation
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) <noreply@anthropic.com>
…hing 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements cursor-based pagination for the Pomodoro history view, introducing a new paginated database fetch method in storage.js and updating the UI in index.html to support a 'Load More' button. The review feedback highlights several critical issues: chronological ordering can break when a single day's entries span across page boundaries, an infinite loop can freeze the browser if page loading fails while searching for a specific item, duplicate requests can occur if the 'Load More' button is clicked rapidly, and records might be skipped at page boundaries due to using a non-unique index (start_time) for pagination.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // 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 += `<h3>${date}</h3>`; | ||
|
|
||
| // 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] | ||
| ? ` · <span style="color:var(--success);">${escapeHtml(historyTodoMap[p.linked_todo_id])}</span>` : ''; | ||
| html += ` | ||
| <div class="pomodoro-item" data-id="${safeId}"> | ||
| <div> | ||
| <span class="pomodoro-name">${escapeHtml(p.name)}</span> | ||
| <span class="pomodoro-type" style="background:${color}20;color:${color}">${escapeHtml(p.type)}</span> | ||
| <div class="pomodoro-meta">${time} - ${p.duration_minutes} min${linkedInfo}</div> | ||
| </div> | ||
| <div class="pomodoro-actions"> | ||
| <button class="delete-btn" title="Delete">✕</button> | ||
| </div> | ||
| const linkedInfo = p.linked_todo_id && historyState.todoMap[p.linked_todo_id] | ||
| ? ` · <span style="color:var(--success);">${escapeHtml(historyState.todoMap[p.linked_todo_id])}</span>` : ''; | ||
|
|
||
| const div = document.createElement('div'); | ||
| div.className = 'pomodoro-item'; | ||
| div.dataset.id = safeId; | ||
| div.innerHTML = ` | ||
| <div> | ||
| <span class="pomodoro-name">${escapeHtml(p.name)}</span> | ||
| <span class="pomodoro-type" style="background:${color}20;color:${color}">${escapeHtml(p.type)}</span> | ||
| <div class="pomodoro-meta">${time} - ${p.duration_minutes} min${linkedInfo}</div> | ||
| </div> | ||
| <div class="pomodoro-actions"> | ||
| <button class="delete-btn" title="Delete">✕</button> | ||
| </div> | ||
| `; | ||
| 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); |
There was a problem hiding this comment.
When a single day's pomodoros are split across page boundaries, descending pagination loads the newer items (evening) on page 1 and the older items (morning) on page 2. Because page 2 is appended to the end of the DOM, the older items are rendered after the newer items, completely breaking the morning-to-evening chronological order within that day (e.g., showing 13:00, 15:00, then 09:00, 11:00).
Wrapping each day's items in a container allows us to easily append and re-sort them chronologically.
// Render and append
const fragment = document.createDocumentFragment();
for (const date of groupOrder) {
const items = grouped[date];
// Find or create the day group container
let dayGroup = list.querySelector(`.day-group[data-date="${date}"]`) || fragment.querySelector(`.day-group[data-date="${date}"]`);
let dayItemsContainer;
if (!dayGroup) {
dayGroup = document.createElement('div');
dayGroup.className = 'day-group';
dayGroup.dataset.date = date;
const h3 = document.createElement('h3');
h3.textContent = date;
dayGroup.appendChild(h3);
dayItemsContainer = document.createElement('div');
dayItemsContainer.className = 'day-items';
dayGroup.appendChild(dayItemsContainer);
fragment.appendChild(dayGroup);
} else {
dayItemsContainer = dayGroup.querySelector('.day-items');
}
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 && historyState.todoMap[p.linked_todo_id]
? ` · <span style="color:var(--success);">${escapeHtml(historyState.todoMap[p.linked_todo_id])}</span>` : '';
const div = document.createElement('div');
div.className = 'pomodoro-item';
div.dataset.id = safeId;
div.dataset.startTime = p.start_time;
div.innerHTML = `
<div>
<span class="pomodoro-name">${escapeHtml(p.name)}</span>
<span class="pomodoro-type" style="background:${color}20;color:${color}">${escapeHtml(p.type)}</span>
<div class="pomodoro-meta">${time} - ${p.duration_minutes} min${linkedInfo}</div>
</div>
<div class="pomodoro-actions">
<button class="delete-btn" title="Delete">✕</button>
</div>
`;
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);
});
dayItemsContainer.appendChild(div);
});
// Sort all items in this day container morning to evening
const sortedElements = Array.from(dayItemsContainer.children)
.sort((a, b) => new Date(a.dataset.startTime) - new Date(b.dataset.startTime));
dayItemsContainer.innerHTML = '';
sortedElements.forEach(el => dayItemsContainer.appendChild(el));
}
list.appendChild(fragment);| let item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); | ||
| // 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)}"]`); |
There was a problem hiding this comment.
If loadHistory(false) fails or encounters an error, the error is caught and swallowed inside loadHistory's try-catch block. Since the error is not rethrown and historyState.hasMore remains true, this while loop will continue to execute indefinitely, completely freezing the browser tab.
To prevent this infinite loop, we should check if any progress was made (i.e., if historyState.lastTimestamp changed) after calling loadHistory(false) and break the loop if no new items were loaded.
| let item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); | |
| // 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)}"]`); | |
| 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) { | |
| const prevTimestamp = historyState.lastTimestamp; | |
| await loadHistory(false); | |
| if (historyState.lastTimestamp === prevTimestamp) { | |
| // Prevent infinite loop if no progress is made (e.g. on error or empty page) | |
| break; | |
| } | |
| item = document.querySelector(`.pomodoro-item[data-id="${CSS.escape(id)}"]`); | |
| } |
| const HISTORY_PAGE_SIZE = 50; | ||
| let historyState = { lastTimestamp: null, hasMore: false, todoMap: null, lastDateGroup: null }; | ||
|
|
||
| document.getElementById('history-load-more').addEventListener('click', () => loadHistory(false)); |
There was a problem hiding this comment.
To prevent duplicate items from being loaded and rendered if a user clicks the 'Load More' button multiple times quickly, we should disable the button while the asynchronous loadHistory call is in progress.
| document.getElementById('history-load-more').addEventListener('click', () => loadHistory(false)); | |
| document.getElementById('history-load-more').addEventListener('click', async (e) => { | |
| const btn = e.currentTarget; | |
| if (btn.disabled) return; | |
| btn.disabled = true; | |
| try { | |
| await loadHistory(false); | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| }); |
| 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); | ||
| }); | ||
| }, |
There was a problem hiding this comment.
Using IDBKeyRange.upperBound(beforeTimestamp, true) on a non-unique index like start_time can cause records to be skipped at page boundaries if multiple pomodoros have the exact same start_time (which is common for imported or manually added entries). Since the upper bound is exclusive, any other pomodoros with the same timestamp as the cursor will be excluded from the next page.
Consider documenting this limitation or eventually using a compound index (e.g., [start_time, id]) to ensure unique cursor-based pagination.
…ory load 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Summary
start_timeindexnavigateToPomodoro()auto-loads pages until the target pomodoro is foundChanges
static/js/storage.js: AddedgetPomodorosPage(limit, beforeTimestamp)using IndexedDB cursor with'prev'directiontemplates/index.html: RewroteloadHistory()to use paginated state, added "Load More" button, updatednavigateToPomodoro()Test plan
Closes #100
🤖 Generated with Claude Code