Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 61 additions & 17 deletions static/js/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -998,17 +1009,51 @@
* @returns {Promise<Array>}
*/
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)
pomodoros.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
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);
});
},
Comment on lines +1031 to +1055

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.


/**
* Create a new pomodoro (timer completion)
* @param {object} data - Pomodoro data (name, type, duration_minutes, notes)
Expand Down Expand Up @@ -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 {
Expand Down
159 changes: 98 additions & 61 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ <h2>History</h2>
<button class="secondary" onclick="showAddModal()">+ Add Manual</button>
</div>
<div id="history-list"></div>
<button id="history-load-more" class="secondary" style="display:none; width:100%; margin-top:1rem;">Load More</button>
</div>

<!-- Reports View -->
Expand Down Expand Up @@ -2833,80 +2834,109 @@ <h2 id="edit-modal-title">Edit Pomodoro</h2>
});
});

// 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));

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


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 = '<div class="empty">No pomodoros yet. Start your first timer!</div>';
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 += `<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);
Comment on lines +2887 to +2934

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);


loadMoreBtn.style.display = hasMore ? '' : 'none';
} catch (e) {
console.error('Error loading history:', e);
list.innerHTML = '<div class="empty">Error loading history. Please refresh.</div>';
if (reset) list.innerHTML = '<div class="empty">Error loading history. Please refresh.</div>';
}
}

Expand All @@ -2925,19 +2955,21 @@ <h2 id="edit-modal-title">Edit Pomodoro</h2>
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)}"]`);
Comment on lines +2961 to +2966

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

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');
}
}
Expand Down Expand Up @@ -4246,8 +4278,6 @@ <h2 id="edit-modal-title">Edit Pomodoro</h2>
updateGooglePomodoroCount();
}

// Always load settings and plugins (independent of auth)
await loadSettings();
}

// Update storage mode indicator in header
Expand Down Expand Up @@ -5741,11 +5771,18 @@ <h2 id="edit-modal-title">Edit Pomodoro</h2>
}

(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();
}
Expand Down
Loading