diff --git a/static/js/storage.js b/static/js/storage.js index 6987eff..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); @@ -194,6 +195,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 */ @@ -345,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 */ @@ -652,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) @@ -998,10 +1009,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) @@ -1009,6 +1021,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) @@ -1183,8 +1228,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 8716474..0c39173 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; + } + } - if (!Array.isArray(pomodoros) || pomodoros.length === 0) { + const { pomodoros, hasMore } = await Storage.getPomodorosPage(HISTORY_PAGE_SIZE, historyState.lastTimestamp); + historyState.hasMore = hasMore; + + 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,21 @@

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)}"]`); + // 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) { - // 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'); } } @@ -4246,8 +4278,6 @@

Edit Pomodoro

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

Edit Pomodoro

} (async () => { - await checkAuthStatus(); - await loadWeeklyOverview(); - await loadHistory(); + // 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() + ]); - // Load todos from cloud if logged in, then render + // Phase 3: Sync cloud data then render todos once with fresh state. if (authStatus.logged_in) { await Storage.loadTodosFromCloud(); }