Skip to content

feat: paginate History tab to prevent resource exhaustion#104

Merged
fatherlinux merged 5 commits into
mainfrom
feature/100-history-pagination
Jun 23, 2026
Merged

feat: paginate History tab to prevent resource exhaustion#104
fatherlinux merged 5 commits into
mainfrom
feature/100-history-pagination

Conversation

@fatherlinux

Copy link
Copy Markdown
Member

Summary

  • History tab now loads 50 pomodoros at a time using cursor-based IndexedDB pagination on the start_time index
  • "Load More" button appends the next page with correct date-group continuity across page boundaries
  • navigateToPomodoro() auto-loads pages until the target pomodoro is found
  • All mutation callers (delete, edit, sync) reset to page 1 automatically

Changes

  • static/js/storage.js: Added getPomodorosPage(limit, beforeTimestamp) using IndexedDB cursor with 'prev' direction
  • templates/index.html: Rewrote loadHistory() to use paginated state, added "Load More" button, updated navigateToPomodoro()

Test plan

  • Empty state renders correctly (no errors, no "Load More" button)
  • First page shows 50 items with date grouping
  • "Load More" appends next page with correct date-group continuity
  • "Load More" button hides when all data loaded
  • Delete resets to page 1
  • 133 existing tests pass

Closes #100

🤖 Generated with Claude Code

fatherlinux and others added 2 commits June 23, 2026 13:31
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread templates/index.html
Comment on lines +2887 to +2934
// 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]
? ` &middot; <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">&#10005;</button>
</div>
const linkedInfo = p.linked_todo_id && historyState.todoMap[p.linked_todo_id]
? ` &middot; <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">&#10005;</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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]
                            ? ` &middot; <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">&#10005;</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);

Comment thread templates/index.html
Comment on lines +2961 to +2966
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)}"]`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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)}"]`);
}

Comment thread templates/index.html
const HISTORY_PAGE_SIZE = 50;
let historyState = { lastTimestamp: null, hasMore: false, todoMap: null, lastDateGroup: null };

document.getElementById('history-load-more').addEventListener('click', () => loadHistory(false));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;
}
});

Comment thread static/js/storage.js
Comment on lines +1019 to +1043
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);
});
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

fatherlinux and others added 3 commits June 23, 2026 14:33
…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>
@fatherlinux fatherlinux merged commit f4b07e1 into main Jun 23, 2026
2 of 3 checks passed
@fatherlinux fatherlinux deleted the feature/100-history-pagination branch June 23, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Paginate the History tab to prevent resource exhaustion on load

1 participant